diff --git a/core/src/main/java/com/opencontext/config/EmbeddingConfig.java b/core/src/main/java/com/opencontext/config/EmbeddingConfig.java new file mode 100644 index 0000000..ef4a02b --- /dev/null +++ b/core/src/main/java/com/opencontext/config/EmbeddingConfig.java @@ -0,0 +1,38 @@ +package com.opencontext.config; + +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.ollama.OllamaEmbeddingModel; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for embedding model integration. + * Sets up Ollama embedding model for text vectorization. + */ +@Slf4j +@Configuration +public class EmbeddingConfig { + + @Value("${app.ollama.api.url}") + private String ollamaBaseUrl; + + @Value("${app.ollama.embedding.model}") + private String ollamaModel; + + /** + * Creates and configures the Ollama embedding model bean. + * + * @return configured EmbeddingModel instance + */ + @Bean + public EmbeddingModel embeddingModel() { + log.info("Configuring Ollama embedding model: {} at {}", ollamaModel, ollamaBaseUrl); + + return OllamaEmbeddingModel.builder() + .baseUrl(ollamaBaseUrl) + .modelName(ollamaModel) + .build(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/config/OpenApiConfig.java b/core/src/main/java/com/opencontext/config/OpenApiConfig.java new file mode 100644 index 0000000..acf10c7 --- /dev/null +++ b/core/src/main/java/com/opencontext/config/OpenApiConfig.java @@ -0,0 +1,38 @@ +package com.opencontext.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenAPI (Swagger) configuration for OpenContext API documentation + */ +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("OpenContext API") + .description("Self-hosted AI Context Engine with hierarchical RAG search capabilities") + .version("1.0.0") + .contact(new Contact() + .name("OpenContext Team") + .url("https://github.com/OpenContextAI/open-context"))) + .addServersItem(new Server() + .url("http://localhost:8080") + .description("Local development server")) + .components(new Components() + .addSecuritySchemes("ApiKeyAuth", new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("X-API-KEY") + .description("API Key for accessing admin endpoints"))); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/controller/DocsSearchController.java b/core/src/main/java/com/opencontext/controller/DocsSearchController.java new file mode 100644 index 0000000..256e6b7 --- /dev/null +++ b/core/src/main/java/com/opencontext/controller/DocsSearchController.java @@ -0,0 +1,95 @@ +package com.opencontext.controller; + +import com.opencontext.common.CommonResponse; +import com.opencontext.dto.GetContentRequest; +import com.opencontext.dto.GetContentResponse; +import com.opencontext.dto.SearchResultsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "MCP Search", description = "APIs for exploratory and focused retrieval used by OpenContext MCP tools. No authentication required.") +public interface DocsSearchController { + + @GetMapping("/search") + @Operation( + summary = "Exploratory search (find_knowledge)", + description = "Returns top-k structured chunk summaries based on a natural language query. Snippet policy: first 50 chars + '...'." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Search completed", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SearchResultsResponse.class), + examples = @ExampleObject(name = "Sample Search Results", value = """ + { + "success": true, + "data": { + "results": [ + { + "chunkId": "c1d2e3f4-a5b6-7890-1234-567890abcdef", + "title": "5.8.2. Configuring the JWT Authentication Converter", + "snippet": "To customize the conversion from a JWT to an Auth...", + "relevanceScore": 0.92, + "breadcrumbs": ["Chapter 5", "Security", "JWT"] + } + ] + }, + "message": "Search completed successfully" + } + """)) + ), + @ApiResponse(responseCode = "400", description = "Validation failed") + }) + ResponseEntity> search( + @Parameter(description = "User search query", example = "Spring Security JWT filter configuration", required = true) + @RequestParam String query, + @Parameter(description = "Max number of results", example = "5") + @RequestParam(defaultValue = "5") Integer topK); + + @PostMapping(value = "/get-content", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Focused retrieval (get_content)", + description = "Returns the full original text of a selected chunk. If token count exceeds maxTokens, text is truncated from the end." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Content retrieved", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = GetContentResponse.class), + examples = @ExampleObject(name = "Sample Content", value = """ + { + "success": true, + "data": { + "content": "### 5.8.2. Configuring the JWT Authentication Converter...", + "tokenInfo": {"tokenizer": "tiktoken-cl100k_base", "actualTokens": 789} + }, + "message": "Content retrieved successfully" + } + """)) + ), + @ApiResponse(responseCode = "400", description = "Validation failed") + }) + ResponseEntity> getContent( + @RequestBody( + required = true, + description = "Chunk selection and optional token limit", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = GetContentRequest.class), + examples = @ExampleObject(value = """ + { + "chunkId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "maxTokens": 8000 + } + """)) + ) GetContentRequest request); +} diff --git a/core/src/main/java/com/opencontext/controller/DocsSourceController.java b/core/src/main/java/com/opencontext/controller/DocsSourceController.java index 02e1532..7511e4e 100644 --- a/core/src/main/java/com/opencontext/controller/DocsSourceController.java +++ b/core/src/main/java/com/opencontext/controller/DocsSourceController.java @@ -31,7 +31,7 @@ name = "Source Document Management", description = "Admin APIs for document ingestion pipeline management. Requires X-API-KEY authentication." ) -@SecurityRequirement(name = "X-API-KEY") +@SecurityRequirement(name = "ApiKeyAuth") public interface DocsSourceController { /** @@ -61,9 +61,8 @@ public interface DocsSourceController { content = @Content( mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, examples = @ExampleObject( - name = "File Upload", - description = "Upload a PDF document", - value = "file: [binary PDF content]" + name = "Upload a Markdown file", + value = "(Use the file picker UI to attach a .md file)" ) ) ) @@ -263,7 +262,7 @@ ResponseEntity>> getAllSourceDocu @ApiResponse(responseCode = "409", description = "Conflict - document is currently being processed") }) ResponseEntity> resyncSourceDocument( - @Parameter(description = "Source document ID", required = true) + @Parameter(description = "Source document ID", required = true, example = "a1b2c3d4-e5f6-7890-1234-567890abcdef") @PathVariable UUID sourceId ); @@ -311,7 +310,7 @@ ResponseEntity> resyncSourceDocument( @ApiResponse(responseCode = "409", description = "Conflict - document is currently being processed") }) ResponseEntity> deleteSourceDocument( - @Parameter(description = "Source document ID", required = true) + @Parameter(description = "Source document ID", required = true, example = "a1b2c3d4-e5f6-7890-1234-567890abcdef") @PathVariable UUID sourceId ); } \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/controller/SearchController.java b/core/src/main/java/com/opencontext/controller/SearchController.java new file mode 100644 index 0000000..14007bb --- /dev/null +++ b/core/src/main/java/com/opencontext/controller/SearchController.java @@ -0,0 +1,74 @@ +package com.opencontext.controller; + +import com.opencontext.common.CommonResponse; +import com.opencontext.dto.GetContentResponse; +import com.opencontext.dto.GetContentRequest; +import com.opencontext.dto.SearchResultItem; +import com.opencontext.dto.SearchResultsResponse; +import com.opencontext.service.ContentRetrievalService; +import com.opencontext.service.SearchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * MCP 검색 API 컨트롤러 + * find_knowledge와 get_content MCP 도구를 위한 엔드포인트 제공 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class SearchController implements DocsSearchController { + + private final SearchService searchService; + private final ContentRetrievalService contentRetrievalService; + + /** + * 하이브리드 검색 수행 - find_knowledge MCP 도구 + */ + @Override + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam String query, + @RequestParam(defaultValue = "5") Integer topK) { + + log.info("검색 요청: query='{}', topK={}", query, topK); + + if (query == null || query.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(CommonResponse.error("Query cannot be empty", "VALIDATION_FAILED")); + } + + List results = searchService.search(query.trim(), topK); + SearchResultsResponse responseData = SearchResultsResponse.builder() + .results(results) + .build(); + return ResponseEntity.ok(CommonResponse.success(responseData, "Search completed successfully")); + } + + /** + * 청크 콘텐츠 조회 - get_content MCP 도구 + */ + @Override + @PostMapping(value = "/get-content", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getContent(@jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody GetContentRequest request) { + if (request == null || request.getChunkId() == null || request.getChunkId().isBlank()) { + return ResponseEntity.badRequest() + .body(CommonResponse.error("chunkId is required", "VALIDATION_FAILED")); + } + String chunkId = request.getChunkId(); + Integer maxTokens = request.getMaxTokens(); + if (maxTokens != null && maxTokens <= 0) { + return ResponseEntity.badRequest() + .body(CommonResponse.error("maxTokens must be positive", "VALIDATION_FAILED")); + } + log.info("콘텐츠 조회 요청: chunkId={}, maxTokens={}", chunkId, maxTokens); + GetContentResponse response = contentRetrievalService.getContent(chunkId, maxTokens); + return ResponseEntity.ok(CommonResponse.success(response, "Content retrieved successfully")); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/controller/SourceController.java b/core/src/main/java/com/opencontext/controller/SourceController.java index c4028b9..6ac73a3 100644 --- a/core/src/main/java/com/opencontext/controller/SourceController.java +++ b/core/src/main/java/com/opencontext/controller/SourceController.java @@ -68,7 +68,7 @@ public class SourceController implements DocsSourceController{ * @return 업로드 결과 및 문서 정보 */ @Override - @PostMapping("/upload") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> uploadFile( @RequestParam("file") MultipartFile file) { log.info("File upload requested: filename={}, size={}", file.getOriginalFilename(), file.getSize()); diff --git a/core/src/main/java/com/opencontext/dto/GetContentRequest.java b/core/src/main/java/com/opencontext/dto/GetContentRequest.java new file mode 100644 index 0000000..cccf609 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/GetContentRequest.java @@ -0,0 +1,32 @@ +package com.opencontext.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "DTO to request full content of a specific chunk") +@Getter +@lombok.Setter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class GetContentRequest { + + @Schema(description = "Chunk ID to retrieve content for", requiredMode = Schema.RequiredMode.REQUIRED, + example = "a1b2c3d4-e5f6-7890-1234-567890abcdef-chunk-0") + @NotBlank(message = "chunkId is required") + @JsonAlias({"chunkID", "chunk_id", "id"}) + @JsonProperty("chunkId") + private String chunkId; + + @Schema(description = "Maximum number of tokens to return", defaultValue = "25000", example = "8000") + @Positive(message = "maxTokens must be positive") + @JsonProperty("maxTokens") + private Integer maxTokens; + + // Use default constructor + setters for robust Jackson binding +} diff --git a/core/src/main/java/com/opencontext/dto/SearchResultItem.java b/core/src/main/java/com/opencontext/dto/SearchResultItem.java index ac3630d..9443695 100644 --- a/core/src/main/java/com/opencontext/dto/SearchResultItem.java +++ b/core/src/main/java/com/opencontext/dto/SearchResultItem.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -11,7 +12,6 @@ import lombok.NoArgsConstructor; import java.util.List; -import java.util.UUID; /** * Individual search result item for exploratory knowledge discovery. @@ -25,11 +25,11 @@ public class SearchResultItem { /** - * Unique identifier of the chunk for subsequent get_content requests. + * Chunk identifier used by get_content (string: "-chunk-"). */ - @Schema(description = "Chunk identifier for content retrieval", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull - private UUID chunkId; + @Schema(description = "Chunk identifier for content retrieval (e.g., '-chunk-0')", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String chunkId; /** * Title or heading of the chunk for user context. diff --git a/core/src/main/java/com/opencontext/dto/SearchResultsResponse.java b/core/src/main/java/com/opencontext/dto/SearchResultsResponse.java new file mode 100644 index 0000000..b634e30 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/SearchResultsResponse.java @@ -0,0 +1,21 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Schema(description = "Response DTO wrapping search results list") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SearchResultsResponse { + + @Schema(description = "Search results ordered by relevance") + private List results; +} diff --git a/core/src/main/java/com/opencontext/service/ChunkingService.java b/core/src/main/java/com/opencontext/service/ChunkingService.java index 17b82bd..1c88a16 100644 --- a/core/src/main/java/com/opencontext/service/ChunkingService.java +++ b/core/src/main/java/com/opencontext/service/ChunkingService.java @@ -7,30 +7,6 @@ import java.util.*; -/** - * 파싱된 문서 요소를 의미 있는 청크로 분할하는 서비스. - * - * Unstructured API의 결과를 기반으로 계층적 구조를 유지하면서 - * 검색에 최적화된 청크를 생성합니다. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class ChunkingService { - - private static final int MAX_CHUNK_SIZE = 1000; // 최대 청크 크기 (문자 수) - private static final int CHUNK_OVERLAP = 200; // 청크 간 중복 크기 - - /** - * 파싱된 요소들을 구조화된 청크로 변환합니다. - * - * @param documentId 문서 ID - * @param parsedElements Unstructured API에서 파싱된 요소 목록 - * Service for splitting parsed document elements into meaningful chunks. - * - * Generates search-optimized chunks while maintaining hierarchical structure, - * based on the results from the Unstructured API. - */ @Slf4j @Service @RequiredArgsConstructor @@ -264,4 +240,4 @@ private static class ChunkContext { this.chunkIndex = globalChunkIndex++; } } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/service/ContentRetrievalService.java b/core/src/main/java/com/opencontext/service/ContentRetrievalService.java new file mode 100644 index 0000000..c48d2e7 --- /dev/null +++ b/core/src/main/java/com/opencontext/service/ContentRetrievalService.java @@ -0,0 +1,239 @@ +package com.opencontext.service; + +import com.opencontext.dto.GetContentResponse; +import com.opencontext.dto.TokenInfo; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * 청크 콘텐츠 조회 및 토큰 제한 처리 서비스 + * PRD 명세에 따라 tiktoken-cl100k_base 토크나이저 기준으로 토큰 제한 적용 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ContentRetrievalService { + + private final RestTemplate restTemplate; + + @Value("${app.elasticsearch.url:http://localhost:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document_chunks_index}") + private String indexName; + + @Value("${app.content.default-max-tokens:25000}") + private int defaultMaxTokens; + + @Value("${app.content.tokenizer:tiktoken-cl100k_base}") + private String tokenizerName; + + /** + * 단일 청크의 전체 콘텐츠를 조회하고 토큰 제한을 적용 + * + * @param chunkId 조회할 청크 ID + * @param maxTokens 최대 토큰 수 (null인 경우 기본값 사용) + * @return 토큰 제한이 적용된 콘텐츠와 토큰 정보 + */ + public GetContentResponse getContent(String chunkId, Integer maxTokens) { + long startTime = System.currentTimeMillis(); + + int effectiveMaxTokens = maxTokens != null ? maxTokens : defaultMaxTokens; + + log.info("청크 콘텐츠 조회 시작: chunkId={}, maxTokens={}", chunkId, effectiveMaxTokens); + + try { + // 1단계: Elasticsearch에서 청크 내용 조회 + String content = fetchChunkContent(chunkId); + + // 2단계: 토큰 제한 적용 + GetContentResponse response = applyTokenLimit(content, effectiveMaxTokens); + + long duration = System.currentTimeMillis() - startTime; + log.info("청크 콘텐츠 조회 완료: chunkId={}, 원본길이={}, 토큰수={}, 소요시간={}ms", + chunkId, content.length(), response.getTokenInfo().getActualTokens(), duration); + + return response; + + } catch (BusinessException e) { + throw e; // 비즈니스 예외는 그대로 전파 + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("청크 콘텐츠 조회 실패: chunkId={}, 소요시간={}ms, 오류={}", + chunkId, duration, e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Content retrieval failed: " + e.getMessage()); + } + } + + /** + * Elasticsearch에서 특정 청크의 콘텐츠 조회 + */ + private String fetchChunkContent(String chunkId) { + log.debug("Elasticsearch에서 청크 조회: chunkId={}", chunkId); + + try { + String getUrl = elasticsearchUrl + "/" + indexName + "/_doc/" + chunkId; + + // _source 필터를 사용하여 content 필드만 조회 (성능 최적화) + String getUrlWithSource = getUrl + "?_source=content"; + + ResponseEntity response = restTemplate.getForEntity(getUrlWithSource, Map.class); + + if (response.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new BusinessException(ErrorCode.CHUNK_NOT_FOUND, + "Chunk not found: " + chunkId); + } + + if (response.getStatusCode() != HttpStatus.OK) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Failed to fetch chunk with status: " + response.getStatusCode()); + } + + Map responseBody = response.getBody(); + if (responseBody == null || !Boolean.TRUE.equals(responseBody.get("found"))) { + throw new BusinessException(ErrorCode.CHUNK_NOT_FOUND, + "Chunk not found: " + chunkId); + } + + // _source에서 content 추출 + Map source = (Map) responseBody.get("_source"); + if (source == null) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Content source is null for chunk: " + chunkId); + } + + String content = (String) source.get("content"); + if (content == null) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Content field is null for chunk: " + chunkId); + } + + log.debug("청크 콘텐츠 조회 성공: chunkId={}, 길이={}", chunkId, content.length()); + return content; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("Elasticsearch 청크 조회 실패: chunkId={}, 오류={}", chunkId, e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Failed to fetch chunk from Elasticsearch: " + e.getMessage()); + } + } + + /** + * 콘텐츠에 토큰 제한을 적용하고 응답 DTO 생성 + * PRD 정책: maxTokens 초과 시 텍스트 끝부분을 잘라냄 (앞부분 우선 보존) + */ + private GetContentResponse applyTokenLimit(String content, int maxTokens) { + log.debug("토큰 제한 적용: 원본길이={}, maxTokens={}", content.length(), maxTokens); + + try { + // 현재 콘텐츠의 토큰 수 계산 + int currentTokens = calculateTokenCount(content); + + String finalContent = content; + int actualTokens = currentTokens; + + // 토큰 수가 제한을 초과하는 경우 텍스트 끝부분을 잘라냄 + if (currentTokens > maxTokens) { + log.debug("토큰 제한 초과, 텍스트 자르기: 현재토큰={}, 제한토큰={}", currentTokens, maxTokens); + + finalContent = truncateContentByTokens(content, maxTokens); + actualTokens = calculateTokenCount(finalContent); + + log.debug("텍스트 자르기 완료: 최종길이={}, 최종토큰={}", finalContent.length(), actualTokens); + } + + // 토큰 정보 생성 + TokenInfo tokenInfo = TokenInfo.builder() + .tokenizer(tokenizerName) + .actualTokens(actualTokens) + .build(); + + // 응답 DTO 생성 + return GetContentResponse.builder() + .content(finalContent) + .tokenInfo(tokenInfo) + .build(); + + } catch (Exception e) { + log.error("토큰 제한 적용 실패: 오류={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Token limit processing failed: " + e.getMessage()); + } + } + + /** + * tiktoken-cl100k_base 토크나이저 기준으로 토큰 수 계산 + * 간단한 근사치 계산 (정확한 구현을 위해서는 실제 tiktoken 라이브러리 필요) + */ + private int calculateTokenCount(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + + // 간단한 토큰 수 근사 계산 + // 실제로는 tiktoken Java 바인딩이나 외부 API를 사용해야 함 + // 현재는 영어 기준 평균 4글자 = 1토큰, 한글 기준 1.5글자 = 1토큰으로 근사 + + int englishChars = 0; + int koreanChars = 0; + int otherChars = 0; + + for (char c : text.toCharArray()) { + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == ' ') { + englishChars++; + } else if (c >= 0xAC00 && c <= 0xD7AF) { // 한글 범위 + koreanChars++; + } else { + otherChars++; + } + } + + int estimatedTokens = (int) Math.ceil(englishChars / 4.0) + + (int) Math.ceil(koreanChars / 1.5) + + (int) Math.ceil(otherChars / 2.0); + + return Math.max(estimatedTokens, 1); // 최소 1토큰 + } + + /** + * 토큰 수 기준으로 텍스트를 잘라냄 (앞부분 우선 보존) + * PRD 정책: 텍스트 끝부분부터 제거하여 앞부분의 중요한 내용 보존 + */ + private String truncateContentByTokens(String content, int maxTokens) { + if (content == null || content.isEmpty()) { + return content; + } + + // 이진 탐색을 사용하여 적절한 자르기 지점 찾기 + int left = 0; + int right = content.length(); + String result = content; + + while (left < right) { + int mid = (left + right + 1) / 2; + String candidate = content.substring(0, mid); + int candidateTokens = calculateTokenCount(candidate); + + if (candidateTokens <= maxTokens) { + result = candidate; + left = mid; + } else { + right = mid - 1; + } + } + + return result; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/service/IndexingService.java b/core/src/main/java/com/opencontext/service/IndexingService.java index 5453479..75edbd3 100644 --- a/core/src/main/java/com/opencontext/service/IndexingService.java +++ b/core/src/main/java/com/opencontext/service/IndexingService.java @@ -7,6 +7,7 @@ import com.opencontext.exception.BusinessException; import com.opencontext.repository.DocumentChunkRepository; import com.opencontext.repository.SourceDocumentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -15,7 +16,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.Arrays; /** * 임베딩된 청크를 Elasticsearch와 PostgreSQL에 저장하는 서비스. @@ -35,6 +38,7 @@ public class IndexingService { private final DocumentChunkRepository documentChunkRepository; private final SourceDocumentRepository sourceDocumentRepository; private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; @Value("${app.elasticsearch.url:http://localhost:9200}") private String elasticsearchUrl; @@ -97,18 +101,26 @@ private void bulkIndexToElasticsearch(List chunks) { Map indexMeta = Map.of( "index", Map.of("_id", chunk.getChunkId()) ); - bulkBody.append(toJsonString(indexMeta)).append("\n"); - - // 문서 데이터 - Map doc = createElasticsearchDocument(chunk); - bulkBody.append(toJsonString(doc)).append("\n"); + try { + bulkBody.append(objectMapper.writeValueAsString(indexMeta)).append("\n"); + + // 문서 데이터 + Map doc = createElasticsearchDocumentPRD(chunk); + bulkBody.append(objectMapper.writeValueAsString(doc)).append("\n"); + } catch (Exception jsonException) { + log.error("JSON 직렬화 실패, 청크 건너뛰기: chunkId={}, error={}", + chunk.getChunkId(), jsonException.getMessage()); + continue; // 해당 청크는 건너뛰고 계속 진행 + } } - // HTTP 헤더 설정 + // HTTP 헤더 설정 (UTF-8 명시) HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.valueOf("application/x-ndjson")); + headers.setContentType(new MediaType("application", "x-ndjson", StandardCharsets.UTF_8)); - HttpEntity requestEntity = new HttpEntity<>(bulkBody.toString(), headers); + // 본문을 UTF-8 바이트로 전송하여 한글이 '?'로 치환되는 문제 방지 + byte[] requestBytes = bulkBody.toString().getBytes(StandardCharsets.UTF_8); + HttpEntity requestEntity = new HttpEntity<>(requestBytes, headers); // 벌크 API 호출 ResponseEntity> response = restTemplate.exchange( @@ -123,10 +135,24 @@ private void bulkIndexToElasticsearch(List chunks) { "Elasticsearch bulk indexing failed"); } - // 응답에서 오류 확인 + // 벌크 응답에서 오류 확인 Map responseBody = response.getBody(); if (responseBody != null && Boolean.TRUE.equals(responseBody.get("errors"))) { - log.warn("Some documents failed to index in Elasticsearch: {}", responseBody); + // 첫 번째 에러의 상세 정보 추출 + List> items = (List>) responseBody.get("items"); + if (items != null && !items.isEmpty()) { + Map firstItem = items.get(0); + Map indexResult = (Map) firstItem.get("index"); + if (indexResult != null && indexResult.containsKey("error")) { + Map error = (Map) indexResult.get("error"); + String reason = (String) error.get("reason"); + log.error("Elasticsearch bulk indexing error: {}", reason); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Elasticsearch bulk indexing failed: " + reason); + } + } + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Elasticsearch bulk indexing failed with errors"); } long totalDuration = System.currentTimeMillis() - esStartTime; @@ -207,26 +233,42 @@ private int saveChunkHierarchyToPostgreSQL(UUID documentId, List createElasticsearchDocument(StructuredChunk chunk) { + private Map createElasticsearchDocumentPRD(StructuredChunk chunk) { Map doc = new HashMap<>(); - doc.put("document_id", chunk.getDocumentId()); - doc.put("chunk_id", chunk.getChunkId()); - - // ✅ content 필드 정리하여 저장 (JSON 파싱 에러 방지) - String cleanContent = sanitizeContent(chunk.getContent()); - doc.put("content", cleanContent); - - doc.put("title", chunk.getTitle()); - doc.put("hierarchy_level", chunk.getHierarchyLevel()); - doc.put("parent_chunk_id", chunk.getParentChunkId()); - doc.put("element_type", chunk.getElementType()); + // 루트 필드 (camelCase) + doc.put("chunkId", chunk.getChunkId()); + doc.put("sourceDocumentId", chunk.getDocumentId()); + doc.put("content", sanitizeContent(chunk.getContent())); doc.put("embedding", chunk.getEmbedding()); - doc.put("metadata", chunk.getMetadata()); - doc.put("indexed_at", new Date()); + doc.put("indexedAt", java.time.Instant.now().toString()); // ISO 문자열 + + // metadata 구조 + Map metadata = new HashMap<>(); + metadata.put("title", chunk.getTitle()); + metadata.put("hierarchyLevel", chunk.getHierarchyLevel()); + metadata.put("sequenceInDocument", 0); // 기본값 + metadata.put("language", "ko"); // 한국어 기본값 + // 실제 파일 타입을 SourceDocument에서 조회하여 반영 + String resolvedFileType = "UNKNOWN"; + try { + UUID srcId = UUID.fromString(chunk.getDocumentId()); + resolvedFileType = sourceDocumentRepository.findById(srcId) + .map(SourceDocument::getFileType) + .orElse("UNKNOWN"); + } catch (Exception e) { + log.warn("Failed to resolve fileType for documentId={}, defaulting to UNKNOWN", chunk.getDocumentId()); + } + metadata.put("fileType", resolvedFileType); + + // breadcrumbs 처리 (기본값: 빈 배열) + metadata.put("breadcrumbs", Arrays.asList()); // 빈 배열 기본값 + + doc.put("metadata", metadata); + return doc; } @@ -256,31 +298,4 @@ private String sanitizeContent(String content) { .trim(); } - /** - * 객체를 JSON 문자열로 변환합니다. - * 실제 구현에서는 Jackson ObjectMapper를 사용해야 합니다. - */ - private String toJsonString(Object obj) { - // 간단한 구현 - 실제로는 ObjectMapper를 사용해야 함 - if (obj instanceof Map) { - Map map = (Map) obj; - StringBuilder json = new StringBuilder("{"); - boolean first = true; - for (Map.Entry entry : map.entrySet()) { - if (!first) json.append(","); - json.append("\"").append(entry.getKey()).append("\":"); - if (entry.getValue() instanceof String) { - json.append("\"").append(entry.getValue()).append("\""); - } else if (entry.getValue() instanceof Map) { - json.append(toJsonString(entry.getValue())); - } else { - json.append(entry.getValue()); - } - first = false; - } - json.append("}"); - return json.toString(); - } - return obj.toString(); - } } diff --git a/core/src/main/java/com/opencontext/service/SearchService.java b/core/src/main/java/com/opencontext/service/SearchService.java new file mode 100644 index 0000000..5764f0c --- /dev/null +++ b/core/src/main/java/com/opencontext/service/SearchService.java @@ -0,0 +1,318 @@ +package com.opencontext.service; + +import com.opencontext.dto.SearchResultItem; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +/** + * Elasticsearch 하이브리드 검색 서비스 + * BM25 키워드 검색과 벡터 유사도 검색을 결합하여 최적의 검색 결과 제공 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SearchService { + + private final EmbeddingModel embeddingModel; + private final RestTemplate restTemplate; + + @Value("${app.elasticsearch.url:http://localhost:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document_chunks_index}") + private String indexName; + + @Value("${app.search.snippet-max-length:50}") + private int snippetMaxLength; + + @Value("${app.search.bm25-weight:0.3}") + private double bm25Weight; + + @Value("${app.search.vector-weight:0.7}") + private double vectorWeight; + + /** + * 하이브리드 검색 실행 - 키워드 검색과 의미 검색을 결합 + * + * @param query 검색어 + * @param topK 반환할 최대 결과 수 + * @return 관련도 순으로 정렬된 검색 결과 목록 + */ + public List search(String query, int topK) { + long startTime = System.currentTimeMillis(); + + log.info("하이브리드 검색 시작: query='{}', topK={}", query, topK); + + try { + // 1단계: 검색어를 임베딩 벡터로 변환 (float 타입으로 ES 호환성 확보) + List queryEmbedding = generateQueryEmbedding(query); + + // 2단계: Elasticsearch 하이브리드 쿼리 실행 + Map searchResponse = executeElasticsearchQuery(query, queryEmbedding, topK); + + // 3단계: 검색 결과를 DTO로 변환 + List results = parseSearchResults(searchResponse); + + long duration = System.currentTimeMillis() - startTime; + log.info("하이브리드 검색 완료: query='{}', 결과수={}, 소요시간={}ms", + query, results.size(), duration); + + return results; + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("하이브리드 검색 실패: query='{}', 소요시간={}ms, 오류={}", + query, duration, e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Search operation failed: " + e.getMessage()); + } + } + + /** + * 검색어를 임베딩 벡터로 변환 + * ES cosineSimilarity 함수 호환을 위해 List 타입 사용 + */ + private List generateQueryEmbedding(String query) { + log.debug("쿼리 임베딩 생성: query='{}'", query); + + try { + TextSegment textSegment = TextSegment.from(query); + Embedding embedding = embeddingModel.embed(textSegment).content(); + + // float 배열을 List로 변환 (ES 호환성) + List embeddingVector = new ArrayList<>(); + float[] vector = embedding.vector(); + for (float value : vector) { + embeddingVector.add(value); + } + + log.debug("쿼리 임베딩 생성 완료: 차원수={}", embedding.dimension()); + return embeddingVector; + + } catch (Exception e) { + log.error("쿼리 임베딩 생성 실패: query='{}', 오류={}", query, e.getMessage(), e); + throw new BusinessException(ErrorCode.EMBEDDING_GENERATION_FAILED, + "Failed to generate query embedding: " + e.getMessage()); + } + } + + /** + * Elasticsearch에 하이브리드 검색 쿼리 실행 + */ + private Map executeElasticsearchQuery(String query, List queryEmbedding, int topK) { + log.debug("Elasticsearch 쿼리 실행: topK={}", topK); + + try { + Map searchQuery = buildHybridSearchQuery(query, queryEmbedding, topK); + String searchUrl = elasticsearchUrl + "/" + indexName + "/_search"; + + ResponseEntity> response = restTemplate.postForEntity( + searchUrl, searchQuery, (Class>) (Class) Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Elasticsearch search failed with status: " + response.getStatusCode()); + } + + Map responseBody = response.getBody(); + if (responseBody == null) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Empty response from Elasticsearch"); + } + + log.debug("검색 쿼리 실행 성공"); + return responseBody; + + } catch (Exception e) { + log.error("검색 쿼리 실행 실패: 오류={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Elasticsearch query execution failed: " + e.getMessage()); + } + } + + /** + * BM25 키워드 검색과 벡터 유사도 검색을 결합한 하이브리드 쿼리 구성 + * 각 쿼리를 나란히 배치하여 점수 정상 반영 + */ + private Map buildHybridSearchQuery(String query, List queryEmbedding, int topK) { + + // BM25 키워드 검색 쿼리 + Map bm25Query = Map.of( + "multi_match", Map.of( + "query", query, + "fields", Arrays.asList("content^2", "metadata.title^1.5"), + "type", "best_fields", + "fuzziness", "AUTO", + "boost", bm25Weight + ) + ); + + // 벡터 유사도 검색 쿼리 (가중치를 스크립트 내부에서 적용) + Map vectorQuery = Map.of( + "script_score", Map.of( + "query", Map.of("match_all", Map.of()), + "script", Map.of( + "source", "(cosineSimilarity(params.query_vector, 'embedding') + 1.0) * params.vector_weight", + "params", Map.of( + "query_vector", queryEmbedding, + "vector_weight", vectorWeight + ) + ) + ) + ); + + // 하이브리드 쿼리 (bool.should에 두 쿼리를 나란히 배치) + Map hybridQuery = Map.of( + "bool", Map.of( + "should", Arrays.asList(bm25Query, vectorQuery) + ) + ); + + // 최종 검색 쿼리 + return Map.of( + "size", topK, + "query", hybridQuery, + "_source", Arrays.asList( + "chunkId", "metadata.title", "content", "metadata.hierarchyLevel", + "sourceDocumentId", "metadata.fileType", "metadata" + ), + "sort", Arrays.asList( + Map.of("_score", Map.of("order", "desc")) + ) + ); + } + + /** + * Elasticsearch 응답을 SearchResultItem 목록으로 변환 + * 응답 내 최대 점수 대비 상대적 정규화 적용 + */ + private List parseSearchResults(Map response) { + log.debug("검색 결과 파싱 중"); + + try { + Map hits = (Map) response.get("hits"); + if (hits == null) { + log.warn("Elasticsearch 응답에 'hits' 필드가 없음"); + return Collections.emptyList(); + } + + List> hitList = (List>) hits.get("hits"); + if (hitList == null || hitList.isEmpty()) { + log.info("검색 결과가 없음"); + return Collections.emptyList(); + } + + // 응답 내 최대 점수 계산 (상대적 정규화를 위함) + double maxScore = hitList.stream() + .mapToDouble(hit -> ((Number) hit.get("_score")).doubleValue()) + .max() + .orElse(1.0); + + List results = new ArrayList<>(); + + for (Map hit : hitList) { + try { + SearchResultItem item = parseSearchHit(hit, maxScore); + if (item != null) { + results.add(item); + } + } catch (Exception e) { + log.warn("검색 결과 파싱 실패, 건너뛰기: {}", e.getMessage()); + // 개별 hit 파싱 실패는 전체 검색을 중단하지 않음 + } + } + + log.debug("검색 결과 파싱 완료: 결과수={}", results.size()); + return results; + + } catch (Exception e) { + log.error("검색 결과 파싱 실패: 오류={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Failed to parse search results: " + e.getMessage()); + } + } + + /** + * 개별 검색 결과를 SearchResultItem으로 변환 + */ + private SearchResultItem parseSearchHit(Map hit, double maxScore) { + Map source = (Map) hit.get("_source"); + if (source == null) { + return null; + } + + // PRD 스키마에 따른 필드 추출 + String chunkId = (String) source.get("chunkId"); + String content = (String) source.get("content"); + Double score = ((Number) hit.get("_score")).doubleValue(); + + // PRD 스키마: title은 metadata.title에 위치 + String title = extractTitle(source); + + // PRD 정책에 따른 스니펫 생성 + String snippet = generateSnippet(content); + + // 응답 내 최대 점수 대비 상대적 정규화 + double relevanceScore = normalizeScore(score, maxScore); + + return SearchResultItem.builder() + .chunkId(chunkId) + .title(title != null ? title : "제목 없음") + .snippet(snippet) + .relevanceScore(relevanceScore) + .build(); + } + + /** + * 스키마에서 제목 추출 (metadata.title) + */ + private String extractTitle(Map source) { + Map metadata = (Map) source.get("metadata"); + if (metadata != null) { + return (String) metadata.get("title"); + } + return null; + } + + /** + * 스니펫 생성 + * - 기본 길이: 50자 + * - 50자 초과 시: 앞 50자 + "..." (항상 추가) + * - 50자 미만 시: 원본 그대로 (... 생략) + */ + private String generateSnippet(String content) { + if (content == null || content.trim().isEmpty()) { + return "내용이 없습니다"; + } + + String cleanContent = content.trim(); + + if (cleanContent.length() <= snippetMaxLength) { + return cleanContent; + } + + return cleanContent.substring(0, snippetMaxLength) + "..."; + } + + /** + * 점수 정규화 - 응답 내 최대 점수 대비 상대적 비율로 계산 + */ + private double normalizeScore(double score, double maxScore) { + if (maxScore <= 0) { + return 0.0; + } + return score / maxScore; + } +} \ No newline at end of file diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index 61ad4d0..ba47637 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -35,14 +35,29 @@ spring: max-file-size: 100MB max-request-size: 100MB -# Elasticsearch Configuration (Development) -elasticsearch: - hosts: localhost:9200 - -# Ollama Configuration (Development) -ollama: - base-url: http://localhost:11434 - model: dengcao/Qwen3-Embedding-0.6B:F16 +# Application-specific Configuration +app: + # Elasticsearch Configuration + elasticsearch: + url: http://localhost:9200 + index: document_chunks_index + + # Ollama Configuration + ollama: + api: + url: http://localhost:11434 + embedding: + model: dengcao/Qwen3-Embedding-0.6B:F16 + + # Embedding Configuration + embedding: + batch-size: 10 + + # Search Configuration + search: + snippet-max-length: 50 + bm25-weight: 0.3 + vector-weight: 0.7 # MinIO Configuration (Development) minio: diff --git a/core/src/main/resources/db/migration/V2__Add_missing_columns_to_document_chunks.sql b/core/src/main/resources/db/migration/V2__Add_missing_columns_to_document_chunks.sql new file mode 100644 index 0000000..425f365 --- /dev/null +++ b/core/src/main/resources/db/migration/V2__Add_missing_columns_to_document_chunks.sql @@ -0,0 +1,43 @@ +-- Add missing columns to document_chunks table for Entity compatibility +-- This migration aligns the database schema with the current DocumentChunk entity + +-- Add chunk_id column (string representation of the chunk identifier for Elasticsearch consistency) +ALTER TABLE document_chunks ADD COLUMN chunk_id VARCHAR(255); + +-- Add source_document_uuid column (for direct UUID access) +ALTER TABLE document_chunks ADD COLUMN source_document_uuid UUID; + +-- Add parent_chunk_uuid column (for parent relationship as UUID) +ALTER TABLE document_chunks ADD COLUMN parent_chunk_uuid UUID; + +-- Add title column (section heading or title) +ALTER TABLE document_chunks ADD COLUMN title VARCHAR(500); + +-- Add hierarchy_level column (depth level in document structure) +ALTER TABLE document_chunks ADD COLUMN hierarchy_level INTEGER; + +-- Add element_type column (type of original document element) +ALTER TABLE document_chunks ADD COLUMN element_type VARCHAR(50); + +-- Create unique constraint on chunk_id +CREATE UNIQUE INDEX idx_document_chunks_chunk_id ON document_chunks(chunk_id); + +-- Add index for hierarchy_level for performance +CREATE INDEX idx_document_chunks_hierarchy_level ON document_chunks(hierarchy_level); + +-- Add index for element_type +CREATE INDEX idx_document_chunks_element_type ON document_chunks(element_type); + +-- Add index for parent_chunk_uuid +CREATE INDEX idx_document_chunks_parent_chunk_uuid ON document_chunks(parent_chunk_uuid); + +-- Add index for source_document_uuid +CREATE INDEX idx_document_chunks_source_document_uuid ON document_chunks(source_document_uuid); + +-- Add comments for the new columns +COMMENT ON COLUMN document_chunks.chunk_id IS 'String representation of chunk identifier, consistent with Elasticsearch chunkId field'; +COMMENT ON COLUMN document_chunks.source_document_uuid IS 'Direct UUID reference to source document for performance optimization'; +COMMENT ON COLUMN document_chunks.parent_chunk_uuid IS 'Direct UUID reference to parent chunk for hierarchical navigation'; +COMMENT ON COLUMN document_chunks.title IS 'Title or heading of the section this chunk belongs to'; +COMMENT ON COLUMN document_chunks.hierarchy_level IS 'Depth level of this chunk in the document hierarchy (1=root, 2=child, etc.)'; +COMMENT ON COLUMN document_chunks.element_type IS 'Type of the original document element (Title, Header, NarrativeText, etc.)'; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 90416a9..1a1bc06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,13 +60,6 @@ services: - OLLAMA_HOST=0.0.0.0 entrypoint: /bin/sh command: -c "ollama serve & sleep 10 && ollama pull dengcao/Qwen3-Embedding-0.6B:F16 && echo 'Model download completed' && pkill ollama" - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] # Ollama Server for embeddings ollama: @@ -85,13 +78,23 @@ services: # - OLLAMA_NUM_PARALLEL=1 depends_on: - ollama-init - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] + restart: unless-stopped + + # Kibana for Elasticsearch management + kibana: + image: docker.elastic.co/kibana/kibana:8.11.3 + platform: linux/amd64 + container_name: kibana + ports: + - "5601:5601" + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + - XPACK_SECURITY_ENABLED=false + depends_on: + elasticsearch: + condition: service_healthy + networks: + - opencontext-network restart: unless-stopped # MinIO Object Storage diff --git a/mcp-adapter/Dockerfile b/mcp-adapter/Dockerfile index 8473139..dbc6271 100644 --- a/mcp-adapter/Dockerfile +++ b/mcp-adapter/Dockerfile @@ -8,7 +8,8 @@ WORKDIR /app COPY package*.json ./ # Install dependencies (including dev dependencies for build) -RUN npm ci +# Use npm install because package-lock.json is not committed +RUN npm install # Copy source code COPY . .