From bae9acd0931feaf3342570f52c508dadb50d8d7c Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:55:59 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20#218=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20RAG=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rag_responses를 LLM 응답 전에 PROCESSING 상태로 먼저 저장할 수 있도록 answer_text NOT NULL 제약을 완화하고, 상태 전이 전용 메서드(markSuccess/ markFailed)를 엔티티에 추가한다. Worker가 검색 시점의 candidates 없이도 citation을 재조립할 수 있도록 SearchResult -> VectorSearchCandidate, ResponseCitation -> CitationResponse 변환 경로도 함께 추가한다. Co-Authored-By: Claude Fable 5 --- .../domain/rag/entity/RagResponse.java | 22 +++++++++- .../rag/repository/RagResponseRepository.java | 12 ++++++ .../ResponseCitationRepository.java | 4 ++ .../command/RagResponseCommandService.java | 40 ++++++++----------- .../search/dto/VectorSearchCandidate.java | 16 ++++++++ .../search/dto/response/CitationResponse.java | 15 +++++++ .../repository/SearchQueryRepository.java | 4 ++ .../repository/SearchResultRepository.java | 5 +++ ...ter_rag_responses_answer_text_nullable.sql | 2 + 9 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V40__alter_rag_responses_answer_text_nullable.sql diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java index b6610deb..f98c6f32 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java @@ -54,7 +54,8 @@ public class RagResponse extends BaseEntity { @JoinColumn(name = "query_id", nullable = false) private SearchQuery query; - @Column(name = "answer_text", nullable = false, columnDefinition = "TEXT") + // PROCESSING 상태로 처음 저장될 때는 아직 값이 없다 — Worker가 생성을 마치면 markSuccess/markFailed로 채운다. + @Column(name = "answer_text", columnDefinition = "TEXT") private String answerText; // LLM 제공자 이름 @@ -99,4 +100,23 @@ public RagResponse(SearchQuery query, String answerText, String llmProvider, Str this.status = status; this.errorMessage = errorMessage; } + + // Worker가 LLM 생성을 마친 뒤 PROCESSING 상태였던 이 row를 SUCCESS로 채운다. + public void markSuccess(String answerText, String llmModelName, Integer inputTokenCount, + Integer outputTokenCount, Integer latencyMs) { + this.answerText = answerText; + this.llmModelName = llmModelName; + this.inputTokenCount = inputTokenCount; + this.outputTokenCount = outputTokenCount; + this.latencyMs = latencyMs; + this.status = ResultStatus.SUCCESS; + } + + // LLM 호출 실패 시에도 빈손이 아니라 extractive fallback 답변을 채워 넣는다 — status만 FAILED로 + // 남겨 감사 추적을 위한 실패 이력은 유지한다. + public void markFailed(String fallbackAnswerText, String errorMessage) { + this.answerText = fallbackAnswerText; + this.status = ResultStatus.FAILED; + this.errorMessage = errorMessage; + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/RagResponseRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/RagResponseRepository.java index 5ef49364..fa5afcd6 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/RagResponseRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/RagResponseRepository.java @@ -1,8 +1,20 @@ package com.opensource.docgrid.domain.rag.repository; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.search.enums.ResultStatus; public interface RagResponseRepository extends JpaRepository { + + // Worker가 순서대로 하나씩 꺼내 처리한다 — Worker가 1개뿐이라 별도 락/claim 없이도 안전하다. + // query/query.user를 미리 fetch해 Worker가 트랜잭션 밖(WebSocket push 시점)에서 + // job.getQuery().getUser().getEmail()에 접근해도 LazyInitializationException이 나지 않게 한다. + @EntityGraph(attributePaths = {"query", "query.user"}) + Optional findFirstByStatusOrderByCreatedAtAsc(ResultStatus status); + + Optional findByQuery_Id(Long queryId); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java index a145744c..984344a8 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java @@ -1,8 +1,12 @@ package com.opensource.docgrid.domain.rag.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.rag.entity.ResponseCitation; public interface ResponseCitationRepository extends JpaRepository { + + List findByResponse_IdOrderByCitationOrder(Long responseId); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java index 5c9cc663..3fa55711 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java @@ -1,7 +1,6 @@ package com.opensource.docgrid.domain.rag.service.command; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; @@ -15,8 +14,9 @@ /** * RAG 최종 답변 저장 서비스 (F-RAG-03). * - *

OllamaClient.generate() 호출은 한 번에 성공/실패가 갈리는 단일 작업이라, SearchQuery처럼 - * PROCESSING을 먼저 저장하지 않고 결과가 나온 시점에 SUCCESS/FAILED로 한 번에 저장한다. + *

비동기 Job 큐 전환(#218) 이후에는 검색 직후 PROCESSING row를 먼저 저장해두고(createPending), + * Worker가 LLM 생성을 마친 뒤 markSuccess/markFailed로 같은 row를 채운다 — SearchQuery의 + * PROCESSING 선저장 패턴과 동일해졌다. */ @Transactional @Service @@ -24,24 +24,18 @@ public class RagResponseCommandService { private static final String LLM_PROVIDER = "Ollama"; - // answer_text는 NOT NULL 제약이라 실패 시에도 고정 문구를 저장한다. 실제 사유는 errorMessage에 담긴다. - private static final String FAILED_ANSWER_TEXT = "답변 생성에 실패했습니다."; // 검색 결과가 0건이라 LLM을 호출하지 않은 경우의 고정 응답 문구 private static final String NO_CONTEXT_ANSWER_TEXT = "관련 문서를 찾지 못했습니다."; private final RagResponseRepository ragResponseRepository; - public RagResponse createSuccess(SearchQuery query, String promptText, OllamaGenerateResult result) { + // 프롬프트 조립까지는 검색 직후 동기로 끝내고, PROCESSING 상태로 Job 큐에 올린다. + public RagResponse createPending(SearchQuery query, String promptText) { RagResponse ragResponse = RagResponse.builder() .query(query) - .answerText(result.answerText()) .llmProvider(LLM_PROVIDER) - .llmModelName(result.model()) .promptText(promptText) - .inputTokenCount(result.inputTokenCount()) - .outputTokenCount(result.outputTokenCount()) - .latencyMs(result.latencyMs()) - .status(ResultStatus.SUCCESS) + .status(ResultStatus.PROCESSING) .build(); return ragResponseRepository.save(ragResponse); } @@ -56,17 +50,15 @@ public RagResponse createNoContext(SearchQuery query) { return ragResponseRepository.save(ragResponse); } - // REQUIRES_NEW: 상위 트랜잭션이 롤백돼도 FAILED 기록은 독립 트랜잭션으로 저장된다. - @Transactional(propagation = Propagation.REQUIRES_NEW) - public RagResponse createFailed(SearchQuery query, String promptText, String errorMessage) { - RagResponse ragResponse = RagResponse.builder() - .query(query) - .answerText(FAILED_ANSWER_TEXT) - .llmProvider(LLM_PROVIDER) - .promptText(promptText) - .status(ResultStatus.FAILED) - .errorMessage(errorMessage) - .build(); - return ragResponseRepository.save(ragResponse); + // dirty checking으로 갱신 — pending 상태로 이미 저장된 row를 채우는 것이라 save() 불필요. + public void completeSuccess(RagResponse ragResponse, OllamaGenerateResult result) { + ragResponse.markSuccess( + result.answerText(), result.model(), result.inputTokenCount(), result.outputTokenCount(), + result.latencyMs() + ); + } + + public void completeFailed(RagResponse ragResponse, String fallbackAnswerText, String errorMessage) { + ragResponse.markFailed(fallbackAnswerText, errorMessage); } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/dto/VectorSearchCandidate.java b/backend/src/main/java/com/opensource/docgrid/domain/search/dto/VectorSearchCandidate.java index ec640fd5..a3466dd1 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/dto/VectorSearchCandidate.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/dto/VectorSearchCandidate.java @@ -3,6 +3,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; +import com.opensource.docgrid.domain.search.entity.SearchResult; import com.opensource.docgrid.domain.search.repository.VectorSearchRow; /** @@ -36,4 +37,19 @@ public static VectorSearchCandidate from(VectorSearchRow row) { score ); } + + // RAG Worker가 비동기로 citation을 재구성할 때, 이미 저장된 SearchResult(+chunk)에서 다시 조립한다. + public static VectorSearchCandidate from(SearchResult result) { + var chunk = result.getChunk(); + var document = chunk.getDocumentVersion().getDocument(); + return new VectorSearchCandidate( + result.getEmbedding() != null ? result.getEmbedding().getId() : null, + chunk.getId(), + document.getId(), + chunk.getChunkText(), + chunk.getPageNo(), + document.getTitle(), + result.getSimilarityScore() + ); + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java index a05d597c..58714fe1 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java @@ -1,5 +1,6 @@ package com.opensource.docgrid.domain.search.dto.response; +import com.opensource.docgrid.domain.rag.entity.ResponseCitation; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; import io.swagger.v3.oas.annotations.media.Schema; @@ -22,4 +23,18 @@ public static CitationResponse of(int order, VectorSearchCandidate candidate) { candidate.chunkText() ); } + + // GET /search/{queryId} 재조회 시, 이미 영속화된 response_citations에서 그대로 조립한다. + public static CitationResponse from(ResponseCitation citation) { + var chunk = citation.getChunk(); + var document = chunk.getDocumentVersion().getDocument(); + return new CitationResponse( + citation.getCitationLabel(), + document.getId(), + document.getTitle(), + chunk.getId(), + citation.getPageNo(), + citation.getQuotedText() + ); + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java index f4e9ca95..3ecc3859 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java @@ -1,6 +1,7 @@ package com.opensource.docgrid.domain.search.repository; import java.time.LocalDateTime; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,4 +13,7 @@ public interface SearchQueryRepository extends JpaRepository * 대시보드 집계 카드의 최근 검색 요청 수. 기준 시각 이후 생성된 검색 Query를 센다. */ long countByCreatedAtAfter(LocalDateTime since); + + // GET /search/{queryId} 재조회 시, 본인이 요청한 검색인지 소유권을 쿼리 조건으로 바로 걸러낸다. + Optional findByIdAndUser_Id(Long id, Long userId); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchResultRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchResultRepository.java index c7a923b6..e3092fb4 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchResultRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/repository/SearchResultRepository.java @@ -1,8 +1,13 @@ package com.opensource.docgrid.domain.search.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.search.entity.SearchResult; public interface SearchResultRepository extends JpaRepository { + + // RAG Worker가 citation을 재구성할 때, PromptBuilder에 넘겼던 것과 동일한 순서로 다시 읽는다. + List findByQuery_IdOrderByRankNo(Long queryId); } diff --git a/backend/src/main/resources/db/migration/V40__alter_rag_responses_answer_text_nullable.sql b/backend/src/main/resources/db/migration/V40__alter_rag_responses_answer_text_nullable.sql new file mode 100644 index 00000000..614bd8ee --- /dev/null +++ b/backend/src/main/resources/db/migration/V40__alter_rag_responses_answer_text_nullable.sql @@ -0,0 +1,2 @@ +-- 검색-RAG 비동기 처리 전환: RagResponse를 PROCESSING 상태로 먼저 저장할 때는 아직 답변이 없다. +ALTER TABLE rag_responses ALTER COLUMN answer_text DROP NOT NULL; From ef68ba5c9479ff6b6da0b573230a48f61a07d411 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:56:13 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20#218=20RagFacade=EB=A5=BC=20enque?= =?UTF-8?q?ue/processJob=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 generate()는 검색 직후 LLM 호출까지 동기로 한 번에 처리했다. 이를 enqueue()(프롬프트 조립 + PROCESSING 저장, LLM 호출 없음)와 processJob() (Worker가 호출, 실제 OllamaClient 호출 + 결과 영속화)으로 나눈다. processJob()은 인자로 RagResponse 객체가 아니라 id만 받아 메서드 내부에서 다시 조회한다 — Worker가 리포지토리로 꺼낸 job은 그 조회 트랜잭션이 끝난 시점에 detached 상태라, 객체를 그대로 넘기면 markSuccess/markFailed로 값을 바꿔도 dirty checking이 감지하지 못해 DB에 반영되지 않는다. Co-Authored-By: Claude Fable 5 --- .../domain/rag/dto/RagEnqueueOutcome.java | 16 +++ .../docgrid/domain/rag/service/RagFacade.java | 105 ++++++++++++------ 2 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/rag/dto/RagEnqueueOutcome.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/RagEnqueueOutcome.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/RagEnqueueOutcome.java new file mode 100644 index 00000000..814bd1c4 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/RagEnqueueOutcome.java @@ -0,0 +1,16 @@ +package com.opensource.docgrid.domain.rag.dto; + +/** + * RagFacade.enqueue() 반환 타입 — 검색 후보가 없어(NO_CONTEXT) LLM 호출 없이 즉시 끝난 경우와, + * PROCESSING으로 Job 큐에 올라가 Worker의 처리를 기다려야 하는 경우를 구분한다. + */ +public record RagEnqueueOutcome(RagAnswer immediateAnswer, boolean pending) { + + public static RagEnqueueOutcome stillPending() { + return new RagEnqueueOutcome(null, true); + } + + public static RagEnqueueOutcome done(RagAnswer answer) { + return new RagEnqueueOutcome(answer, false); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java index 14d06a1e..b4e59278 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java @@ -7,13 +7,17 @@ import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; import com.opensource.docgrid.domain.rag.dto.RagAnswer; +import com.opensource.docgrid.domain.rag.dto.RagEnqueueOutcome; import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; import com.opensource.docgrid.domain.rag.service.command.RagResponseCommandService; import com.opensource.docgrid.domain.rag.service.command.ResponseCitationCommandService; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; import com.opensource.docgrid.domain.search.entity.SearchQuery; import com.opensource.docgrid.domain.search.entity.SearchResult; +import com.opensource.docgrid.domain.search.repository.SearchResultRepository; import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -22,16 +26,16 @@ /** * RAG 답변 생성 전체 흐름을 조율하는 Facade (F-RAG-05). * + *

비동기 Job 큐 전환(#218) 이후 두 단계로 나뉜다: *

- * 1. candidates가 비어있으면(NO_CONTEXT) LLM 호출 없이 고정 응답 저장
- * 2. PromptBuilder로 프롬프트 조립
- * 3. OllamaClient 호출
- * 4. rag_responses 저장 (성공/실패)
- * 5. 성공 시 response_citations 저장, 실패 시 검색 후보와 안내 답변 반환
+ * 1. enqueue()  — SearchController가 검색 직후 동기 호출. 프롬프트만 조립해 PROCESSING으로 저장하고
+ *                 즉시 반환한다(LLM 호출 없음). candidates가 비어있으면(NO_CONTEXT) 여기서 바로 끝난다.
+ * 2. processJob() — RagJobWorker가 PROCESSING row를 하나씩 꺼내 호출. 실제 OllamaClient 호출과
+ *                    결과 영속화(rag_responses, response_citations)를 담당한다.
  * 
* - *

SearchFacade와 별도 트랜잭션으로 분리되어 있다(SearchController가 순차 호출) — 검색 DB 작업과 - * LLM HTTP 호출을 포함한 RAG DB 작업이 하나의 커넥션을 오래 물고 있지 않도록 하기 위함이다. + *

SearchFacade와 별도 트랜잭션으로 분리되어 있다(SearchController가 순차 호출) — 검색 DB 작업이 + * enqueue()의 짧은 DB 작업과 하나의 커넥션을 오래 물고 있지 않도록 하기 위함이다. */ @Transactional @Service @@ -46,8 +50,8 @@ public class RagFacade { private static final int FALLBACK_EXCERPT_MAX_CODE_POINTS = 300; // topK는 호출자가 1~20까지 자유롭게 요청할 수 있어(SearchRequest), 후보 수를 그대로 프롬프트에 - // 다 넣으면 prefill 시간이 예측 불가능해져 read-timeout(25s)을 넘기는 경우가 생긴다. - // 화면에 보여줄 인용 문서 수(topK)와 별개로, LLM이 실제로 읽는 후보 수는 이 값으로 고정한다. + // 다 넣으면 prefill 시간이 예측 불가능해진다. 화면에 보여줄 인용 문서 수(topK)와 별개로, + // LLM이 실제로 읽는 후보 수는 이 값으로 고정한다. private static final int MAX_PROMPT_CANDIDATES = 3; // PromptBuilder가 LLM에게 무관한 문서일 때 이 문구로만 답하도록 지시한다 — 검색은 됐지만(candidates @@ -58,56 +62,91 @@ public class RagFacade { private final OllamaClient ollamaClient; private final RagResponseCommandService ragResponseCommandService; private final ResponseCitationCommandService responseCitationCommandService; + private final RagResponseRepository ragResponseRepository; + private final SearchResultRepository searchResultRepository; private final EntityManager entityManager; - public RagAnswer generate( - Long queryId, String queryText, List candidates, List searchResults - ) { + public RagEnqueueOutcome enqueue(Long queryId, String queryText, List candidates) { SearchQuery queryRef = entityManager.getReference(SearchQuery.class, queryId); - // 검색 후보가 없으면(NO_CONTEXT) LLM 호출 없이 고정 응답 저장 + // 검색 후보가 없으면(NO_CONTEXT) LLM 호출 없이 고정 응답으로 바로 끝낸다 — Job 큐에 올릴 이유가 없다. if (candidates.isEmpty()) { RagResponse ragResponse = ragResponseCommandService.createNoContext(queryRef); log.info("[RAG] no context queryId={} responseId={}", queryId, ragResponse.getId()); - return RagAnswer.noContext(ragResponse.getAnswerText()); + return RagEnqueueOutcome.done(RagAnswer.noContext(ragResponse.getAnswerText())); } - // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 (LLM 입력은 상위 MAX_PROMPT_CANDIDATES개로 제한) List promptCandidates = candidates.size() > MAX_PROMPT_CANDIDATES ? candidates.subList(0, MAX_PROMPT_CANDIDATES) : candidates; String prompt = promptBuilder.build(queryText, promptCandidates); + RagResponse ragResponse = ragResponseCommandService.createPending(queryRef, prompt); + log.info("[RAG] enqueued queryId={} responseId={}", queryId, ragResponse.getId()); + return RagEnqueueOutcome.stillPending(); + } + + // RagJobWorker가 findFirstByStatusOrderByCreatedAtAsc()로 꺼낸 job은 그 조회 시점에 트랜잭션이 + // 끝나 detached 상태다 — 그 인스턴스를 그대로 받아 markSuccess/markFailed로 값을 바꿔도 이 + // 메서드의 새 트랜잭션에서는 dirty checking이 감지하지 못해 DB에 반영되지 않는다(영원히 + // PROCESSING으로 남아 Worker가 같은 job을 계속 재처리하는 버그로 이어졌었다). 그래서 id만 받아 + // 이 메서드 자신의 트랜잭션 안에서 다시 조회해 반드시 managed 상태로 확보한다. + public void processJob(Long jobId) { + RagResponse job = ragResponseRepository.findById(jobId) + .orElseThrow(() -> new DocGridException(ErrorCode.RAG_ANSWER_NOT_FOUND)); + Long queryId = job.getQuery().getId(); + OllamaGenerateResult result; try { - result = ollamaClient.generate(prompt); + result = ollamaClient.generate(job.getPromptText()); } catch (DocGridException e) { - ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage()); // LLM 장애가 권한 검증을 통과한 벡터 검색 결과까지 숨기지 않도록, 최상위 후보 원문을 - // 그대로 인용해 최소한의 답을 제공한다(extractive fallback). + // 그대로 인용해 최소한의 답을 제공한다(extractive fallback). 이 fallback은 비동기 전환 + // 이전과 달리 rag_responses에 그대로 영속화된다 — 나중에 GET/조회로 이 값을 그대로 돌려준다. + List candidates = loadCandidates(queryId); + String fallbackAnswer = candidates.isEmpty() ? e.getErrorCode().getMessage() + : buildExtractiveFallbackAnswer(candidates); + ragResponseCommandService.completeFailed(job, fallbackAnswer, e.getMessage()); log.warn("[RAG] fallback queryId={} errorCode={}", queryId, e.getErrorCode().getCode()); - return RagAnswer.of(buildExtractiveFallbackAnswer(candidates), candidates); + return; } - // LLM 이후의 영속화 실패는 검색 저하 응답으로 숨기지 않고 Transaction 오류로 전달한다. - RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); - responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); - log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); - - // LLM이 무관하다고 판단해 안내 문구로만 답했으면, 후보 문서를 근거처럼 같이 보여주지 않는다. - // 단, 7B 모델이 정상 답변을 끝낸 뒤 지시문을 메아리처럼 이 문구를 덧붙이는 패턴이 관찰됨 — - // 문구가 답변의 사실상 전부(맨 앞)일 때만 무관으로 취급하고, 정상 답변 중간에 박힌 문구는 - // 그 지점부터 잘라내고 근거 문서는 유지한다. + // LLM이 무관하다고 판단해 안내 문구로만 답했으면, 근거 문서를 같이 보여주지 않는다. 단, 7B + // 모델이 정상 답변을 끝낸 뒤 지시문을 메아리처럼 이 문구를 덧붙이는 패턴이 관찰됨 — 문구가 + // 답변의 사실상 전부(맨 앞)일 때만 무관으로 취급하고, 정상 답변 중간에 박힌 문구는 그 + // 지점부터 잘라내고 근거 문서는 유지한다. 잘라낸 결과를 그대로 영속화해야 GET 조회 시 + // 사용자에게 보이는 값과 DB 값이 일치한다(동기 시절엔 반환값에만 트리밍이 적용되고 DB엔 + // 원문이 남았는데, 비동기에서는 이 row가 유일한 진실 소스라 그대로 두면 안 된다). String answerText = result.answerText(); + List candidates = loadCandidates(queryId); + boolean noRelevant = false; int phraseIndex = answerText != null ? answerText.indexOf(NO_RELEVANT_DOC_PHRASE) : -1; if (phraseIndex >= 0) { if (answerText.strip().startsWith(NO_RELEVANT_DOC_PHRASE)) { - return RagAnswer.of(answerText, List.of()); + noRelevant = true; + } else { + log.warn("[RAG] 정상 답변에 무관 안내 문구 혼입, 해당 지점부터 제거: queryId={} phraseIndex={}", + queryId, phraseIndex); + answerText = answerText.substring(0, phraseIndex).strip(); } - log.warn("[RAG] 정상 답변에 무관 안내 문구 혼입, 해당 지점부터 제거: queryId={} phraseIndex={}", - queryId, phraseIndex); - answerText = answerText.substring(0, phraseIndex).strip(); } - return RagAnswer.of(answerText, candidates); + + ragResponseCommandService.completeSuccess(job, new OllamaGenerateResult( + result.model(), answerText, result.inputTokenCount(), result.outputTokenCount(), result.latencyMs() + )); + + if (!noRelevant) { + List searchResults = searchResultRepository.findByQuery_IdOrderByRankNo(queryId); + responseCitationCommandService.saveAll(job, candidates, searchResults); + } + log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, job.getId(), result.latencyMs()); + } + + // Worker는 검색 시점의 in-memory candidates를 갖고 있지 않으므로, 이미 영속화된 search_results(+chunk)에서 + // 동일한 순서로 다시 조립한다 — PromptBuilder에 넘겼던 것과 citation_order가 어긋나지 않는다. + private List loadCandidates(Long queryId) { + return searchResultRepository.findByQuery_IdOrderByRankNo(queryId).stream() + .map(VectorSearchCandidate::from) + .toList(); } private String buildExtractiveFallbackAnswer(List candidates) { From 2e8b42df1c952e40cd3c86b612b6cc0f39d5609d Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:56:26 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20#218=20RAG=20Job=20Worker=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROCESSING 상태인 RagResponse를 1초 주기로 폴링해 하나씩 순서대로 처리하는 경량 Worker. embedding_jobs용 Worker(heartbeat, lease 복구 등 분산 처리 안전장치 포함)와 달리, 백엔드 인스턴스가 1개뿐이고 Ollama도 GPU 1개라 동시 처리 자체가 불가능하다는 전제 위에서 @Scheduled 폴링 하나로 단순화했다. Worker가 정확히 1개뿐이라는 사실 자체가 "한 번에 하나씩만 Ollama 호출"이라는 동시성 상한을 자연히 만든다. Co-Authored-By: Claude Fable 5 --- .../rag/config/RagSchedulingConfig.java | 16 +++++ .../domain/rag/service/RagJobWorker.java | 65 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/rag/config/RagSchedulingConfig.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/config/RagSchedulingConfig.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/config/RagSchedulingConfig.java new file mode 100644 index 00000000..9d5fc1bf --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/config/RagSchedulingConfig.java @@ -0,0 +1,16 @@ +package com.opensource.docgrid.domain.rag.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * {@code WorkerSchedulingConfig} 등 다른 도메인의 {@code @EnableScheduling}과 별개로 켠다 — + * 그쪽은 {@code indexing.worker.enabled} 조건부라 꺼질 수 있지만, {@link + * com.opensource.docgrid.domain.rag.service.RagJobWorker}는 검색 API의 핵심 경로라 조건 없이 + * 항상 돌아야 한다. {@code @EnableScheduling}을 여러 설정 클래스에 중복 선언해도 Spring이 + * 안전하게 병합하므로 문제없다. + */ +@Configuration +@EnableScheduling +public class RagSchedulingConfig { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java new file mode 100644 index 00000000..9e71b4f5 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java @@ -0,0 +1,65 @@ +package com.opensource.docgrid.domain.rag.service; + +import java.util.Optional; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.rag.controller.RagWebSocketController; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; +import com.opensource.docgrid.domain.search.enums.ResultStatus; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * PROCESSING 상태인 RagResponse를 하나씩 순서대로 꺼내 처리하는 경량 Worker (#218). + * + *

{@code embedding_jobs}용 Worker(heartbeat·lease 복구 등 분산 처리 안전장치 포함, 26개 파일 + * 규모)와 달리, 이 Worker는 백엔드 인스턴스가 1개뿐이고 Ollama도 GPU 1개라 애초에 동시 처리가 + * 불가능하다는 전제 위에서 만들어졌다 — {@code @Scheduled} 폴링 하나로 충분하고, 여러 인스턴스 + * 간 조율(락·lease)은 필요 없다. Worker가 정확히 1개뿐이라는 사실 자체가 Ollama 호출의 + * 동시성 상한을 자연히 1로 만든다 — 기각했던 세마포어 게이트(#218 초안)가 하던 역할을 이 + * 구조가 대신한다. + * + *

{@code processJob()} 실행(=OllamaClient HTTP 호출, 최대 {@code ollama.generate-deadline})이 + * 끝나야 다음 폴링이 실행되므로, 폴링 주기 자체는 혼잡 여부와 무관하게 큐가 밀리지 않는 한 + * 크게 중요하지 않다 — PROCESSING 건이 있으면 그 즉시 다음 턴에 잡힌다. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RagJobWorker { + + private final RagResponseRepository ragResponseRepository; + private final RagFacade ragFacade; + private final RagWebSocketController ragWebSocketController; + + @Scheduled(fixedDelayString = "${rag.worker.polling-interval:1s}") + public void processNext() { + Optional maybeJob = ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING); + if (maybeJob.isEmpty()) { + return; + } + + RagResponse job = maybeJob.get(); + // query/query.user는 findFirstByStatusOrderByCreatedAtAsc()의 @EntityGraph로 이미 로딩돼 + // 있어 detached 상태에서 읽어도 안전하다 — 문제는 "쓰기"(markSuccess 등)뿐이라 + // processJob()에는 id만 넘겨 그 안에서 managed 상태로 다시 조회하게 한다. + Long queryId = job.getQuery().getId(); + String userEmail = job.getQuery().getUser().getEmail(); + + try { + ragFacade.processJob(job.getId()); + } catch (Exception e) { + // processJob() 내부에서 Ollama 관련 실패는 이미 DocGridException으로 잡아 fallback + // 처리하므로, 여기까지 올라오는 예외는 예상 밖의 버그다. Worker 스레드가 죽어서 큐 + // 전체가 멈추는 것보다는, 이 건을 건너뛰고 다음 폴링을 계속 도는 게 낫다. + log.error("[RAG-WORKER] job 처리 중 예상치 못한 예외 queryId={}", queryId, e); + return; + } + + ragWebSocketController.notifyAnswerReady(userEmail, queryId); + } +} From 2cb6a0b3844c6a636c67b19cc91b8d71c2b558a2 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:56:45 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20#218=20RAG=20=EB=8B=B5=EB=B3=80?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20WebSocket=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker가 처리를 마치면 요청한 유저 본인에게만 완료를 push한다. DashboardWebSocketController(/topic/dashboard, 전체 관리자 브로드캐스트) 와 달리 convertAndSendToUser()를 쓰는데, StompAuthChannelInterceptor가 CONNECT 시점에 세션에 붙인 Principal(이메일)로 Spring이 이미 목적지를 세션별로 격리해줘서 대시보드처럼 별도 구독 인가 Interceptor가 필요 없었다. WebSocketConfig에는 /queue 브로커만 추가하면 됐다. Co-Authored-By: Claude Fable 5 --- .../controller/RagWebSocketController.java | 36 +++++++++++++++++++ .../global/config/WebSocketConfig.java | 10 ++++-- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java new file mode 100644 index 00000000..d97ca900 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java @@ -0,0 +1,36 @@ +package com.opensource.docgrid.domain.rag.controller; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +/** + * RAG 답변이 준비됐음을 요청한 사용자에게만 push하는 전송 계층 (#218). + * + *

{@code DashboardWebSocketController}(/topic/dashboard, 전체 브로드캐스트)와 달리, 이건 검색 + * 요청을 보낸 그 유저 한 명에게만 전달돼야 한다 — 관리자 전용 브로드캐스트 채널을 재사용할 수 없는 + * 이유다. {@code convertAndSendToUser}는 {@code StompAuthChannelInterceptor}가 CONNECT 시점에 + * 세션에 부착한 Principal(이메일)로 목적지를 사용자별로 격리한다 — 다른 유저는 같은 목적지 + * ({@code /user/queue/rag-answer})를 구독해도 이 메시지를 받지 않으므로, 대시보드처럼 별도의 + * 구독 인가 Interceptor가 필요 없다. + * + *

본문은 트리거 용도로만 쓴다. {@code useDashboardSocket}과 동일하게, 프론트는 이 메시지를 + * "다시 조회해야 한다"는 신호로만 쓰고 최신 상태는 REST로 다시 읽는다 — Push 페이로드와 실제 + * DB 상태가 어긋날 걱정 없이 항상 단일 진실 소스(REST)를 신뢰할 수 있다. + */ +@Component +@RequiredArgsConstructor +public class RagWebSocketController { + + private static final String RAG_ANSWER_QUEUE = "/queue/rag-answer"; + + private final SimpMessagingTemplate messagingTemplate; + + public void notifyAnswerReady(String userEmail, Long queryId) { + messagingTemplate.convertAndSendToUser(userEmail, RAG_ANSWER_QUEUE, new RagAnswerReadyEvent(queryId)); + } + + private record RagAnswerReadyEvent(Long queryId) { + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java index 34bb1e68..03b88bbe 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java @@ -13,11 +13,15 @@ import lombok.RequiredArgsConstructor; /** - * RAGOps Dashboard 실시간 push를 위한 STOMP endpoint와 Message Broker 설정. + * RAGOps Dashboard 및 RAG 답변 실시간 push를 위한 STOMP endpoint와 Message Broker 설정. * *

인증·인가는 이 설정이 아니라 {@link StompAuthChannelInterceptor}(CONNECT 시점 인증)와 - * {@link DashboardSubscriptionAuthorizationInterceptor}(SUBSCRIBE 시점 인가)가 담당한다. + * {@link DashboardSubscriptionAuthorizationInterceptor}(대시보드 SUBSCRIBE 시점 인가)가 담당한다. * 이 클래스는 전송 계층 구성(endpoint·broker·origin)과 두 Interceptor의 등록 순서만 책임진다. + * + *

{@code /queue}는 RAG 답변 개인 알림({@code convertAndSendToUser})에 쓰인다. 대시보드처럼 + * 별도 구독 인가 Interceptor가 없는 이유는 {@code RagWebSocketController} 문서 참고 — 사용자별 + * 격리가 Spring의 user destination 메커니즘 자체로 이미 보장된다. */ @EnableWebSocketMessageBroker @Configuration @@ -36,7 +40,7 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/topic"); + registry.enableSimpleBroker("/topic", "/queue"); } @Override From 04300b72457784dabf21a1e71e57ade96d527644 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:57:08 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20#218=20=EA=B2=80=EC=83=89-RAG=20A?= =?UTF-8?q?PI=EB=A5=BC=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /search가 더 이상 LLM 응답을 기다리지 않는다 — 검색 결과와 queryId를 즉시 반환하고(ragStatus: PROCESSING, answer: null), 답변 준비는 새 GET /search/{queryId}로 재조회한다(WebSocket 알림 또는 폴백 폴링을 신호로 사용). SearchResponse에 ragStatus 필드를 추가해 프론트가 "아직 생성 전"과 "실패"를 구분할 수 있게 했고, 본인 소유가 아닌 queryId 조회는 RAG_ANSWER_NOT_FOUND(404)로 존재 자체를 숨긴다. Co-Authored-By: Claude Fable 5 --- .../search/controller/SearchController.java | 43 ++++++++++--- .../search/dto/response/SearchResponse.java | 12 ++-- .../query/SearchAnswerQueryService.java | 64 +++++++++++++++++++ .../docgrid/global/exception/ErrorCode.java | 5 ++ 4 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java b/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java index f2dc8b33..3b95db5c 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java @@ -1,18 +1,22 @@ package com.opensource.docgrid.domain.search.controller; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.opensource.docgrid.domain.auth.annotation.CurrentUser; -import com.opensource.docgrid.domain.rag.dto.RagAnswer; +import com.opensource.docgrid.domain.rag.dto.RagEnqueueOutcome; import com.opensource.docgrid.domain.rag.service.RagFacade; import com.opensource.docgrid.domain.search.dto.SearchOutcome; import com.opensource.docgrid.domain.search.dto.request.SearchRequest; import com.opensource.docgrid.domain.search.dto.response.SearchResponse; +import com.opensource.docgrid.domain.search.enums.ResultStatus; import com.opensource.docgrid.domain.search.service.SearchFacade; +import com.opensource.docgrid.domain.search.service.query.SearchAnswerQueryService; import com.opensource.docgrid.global.common.response.ApiResponse; import com.opensource.docgrid.global.common.response.ResponseUtils; @@ -30,16 +34,19 @@ public class SearchController { private final SearchFacade searchFacade; private final RagFacade ragFacade; + private final SearchAnswerQueryService searchAnswerQueryService; @Operation( - summary = "벡터 검색 + RAG 답변 생성", - description = "질문 텍스트를 임베딩 후 pgvector 코사인 유사도 기준 Top-K 문서 청크를 찾고, " - + "그 청크를 근거로 LLM이 생성한 답변(answer)과 출처(citations)를 함께 반환합니다. " + summary = "벡터 검색 + RAG 답변 생성 요청", + description = "질문 텍스트를 임베딩 후 pgvector 코사인 유사도 기준 Top-K 문서 청크를 찾아 즉시 " + + "반환합니다. AI 답변(answer)은 비동기로 생성되며, 응답 시점에는 ragStatus가 PROCESSING이고 " + + "answer는 null입니다 — 완성되면 WebSocket(/user/queue/rag-answer)으로 알림이 오며, 그 신호를 " + + "받으면 GET /search/{queryId}로 최신 상태를 다시 조회하세요. 검색 결과가 없으면(NO_CONTEXT) " + + "answer가 고정 안내 문구와 함께 즉시(ragStatus=SUCCESS) 반환됩니다. " + "topK 기본값은 5이며 1~20 범위에서 지정할 수 있지만, 서버의 최소 유사도 기준을 " + "통과하고 문서별 청크 상한을 적용한 결과만 반환하므로 실제 결과 수는 topK보다 적을 수 있습니다. " + "collectionId를 지정하면 해당 컬렉션 내 문서로 검색 범위를 좁힙니다. " - + "권한이 없는 문서는 결과에 포함되지 않으며, 접근 가능하고 관련성 있는 문서가 없으면 " - + "answer에 고정 안내 문구가 반환됩니다." + + "권한이 없는 문서는 결과에 포함되지 않습니다." ) @PostMapping public ResponseEntity> search( @@ -47,9 +54,27 @@ public ResponseEntity> search( @RequestBody @Valid SearchRequest request ) { SearchOutcome outcome = searchFacade.search(userId, request); - RagAnswer ragAnswer = ragFacade.generate( - outcome.response().queryId(), request.queryText(), outcome.candidates(), outcome.savedResults() + RagEnqueueOutcome ragOutcome = ragFacade.enqueue( + outcome.response().queryId(), request.queryText(), outcome.candidates() ); - return ResponseUtils.ok(outcome.response().withAnswer(ragAnswer.answerText(), ragAnswer.citations())); + if (ragOutcome.pending()) { + return ResponseUtils.ok(outcome.response()); + } + return ResponseUtils.ok(outcome.response().withAnswer( + ResultStatus.SUCCESS, ragOutcome.immediateAnswer().answerText(), ragOutcome.immediateAnswer().citations() + )); + } + + @Operation( + summary = "RAG 답변 상태 재조회", + description = "POST /search 응답의 ragStatus가 PROCESSING이었거나, WebSocket(/user/queue/rag-answer) " + + "알림을 받은 뒤 최신 상태를 확인할 때 호출합니다. 본인이 요청한 검색만 조회할 수 있습니다." + ) + @GetMapping("/{queryId}") + public ResponseEntity> getAnswer( + @Parameter(hidden = true) @CurrentUser Long userId, + @PathVariable Long queryId + ) { + return ResponseUtils.ok(searchAnswerQueryService.getAnswer(queryId, userId)); } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java index 87ec86c1..08e8a743 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java @@ -3,12 +3,16 @@ import java.util.List; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.enums.ResultStatus; import io.swagger.v3.oas.annotations.media.Schema; public record SearchResponse( @Schema(description = "검색 요청 ID (search_queries.id)") Long queryId, @Schema(description = "검색 결과 목록 (유사도 내림차순)") List results, + @Schema(description = "RAG 답변 생성 상태 — PROCESSING이면 answer가 아직 null이라는 뜻이며, " + + "GET /search/{queryId}로 재조회하거나 WebSocket(/user/queue/rag-answer) 알림을 기다려야 한다.") + ResultStatus ragStatus, @Schema(description = "RAG로 생성된 답변, 아직 생성 전이면 null") String answer, @Schema(description = "답변의 근거 출처 목록") List citations ) { @@ -17,14 +21,14 @@ public static SearchResponse of(Long queryId, List candid for (int i = 0; i < candidates.size(); i++) { items.add(SearchResultItem.of(i + 1, candidates.get(i))); } - return new SearchResponse(queryId, List.copyOf(items), null, List.of()); + return new SearchResponse(queryId, List.copyOf(items), ResultStatus.PROCESSING, null, List.of()); } public static SearchResponse empty(Long queryId) { - return new SearchResponse(queryId, List.of(), null, List.of()); + return new SearchResponse(queryId, List.of(), ResultStatus.SUCCESS, null, List.of()); } - public SearchResponse withAnswer(String answer, List citations) { - return new SearchResponse(queryId, results, answer, citations); + public SearchResponse withAnswer(ResultStatus ragStatus, String answer, List citations) { + return new SearchResponse(queryId, results, ragStatus, answer, citations); } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java new file mode 100644 index 00000000..7980bd88 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java @@ -0,0 +1,64 @@ +package com.opensource.docgrid.domain.search.service.query; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; +import com.opensource.docgrid.domain.rag.repository.ResponseCitationRepository; +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.dto.response.CitationResponse; +import com.opensource.docgrid.domain.search.dto.response.SearchResponse; +import com.opensource.docgrid.domain.search.dto.response.SearchResultItem; +import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.entity.SearchResult; +import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; +import com.opensource.docgrid.domain.search.repository.SearchResultRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +/** + * 비동기 RAG 처리(#218)에서 프론트가 WebSocket push를 신호로 삼아 다시 조회하는 GET /search/{queryId} + * 전용 조회 서비스. POST /search 시점의 SearchResponse와 같은 모양을 그대로 재조립해 반환한다 — + * 프론트가 두 응답을 같은 타입으로 다룰 수 있게 하기 위함이다. + */ +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class SearchAnswerQueryService { + + private final SearchQueryRepository searchQueryRepository; + private final RagResponseRepository ragResponseRepository; + private final SearchResultRepository searchResultRepository; + private final ResponseCitationRepository responseCitationRepository; + + public SearchResponse getAnswer(Long queryId, Long userId) { + SearchQuery query = searchQueryRepository.findByIdAndUser_Id(queryId, userId) + .orElseThrow(() -> new DocGridException(ErrorCode.RAG_ANSWER_NOT_FOUND)); + + List savedResults = searchResultRepository.findByQuery_IdOrderByRankNo(query.getId()); + List items = new ArrayList<>(); + for (int i = 0; i < savedResults.size(); i++) { + items.add(SearchResultItem.of(i + 1, VectorSearchCandidate.from(savedResults.get(i)))); + } + + RagResponse ragResponse = ragResponseRepository.findByQuery_Id(query.getId()).orElse(null); + if (ragResponse == null || ragResponse.getStatus() == ResultStatus.PROCESSING) { + return new SearchResponse(queryId, items, ResultStatus.PROCESSING, null, List.of()); + } + + List citations = responseCitationRepository + .findByResponse_IdOrderByCitationOrder(ragResponse.getId()) + .stream() + .map(CitationResponse::from) + .toList(); + + return new SearchResponse(queryId, items, ragResponse.getStatus(), ragResponse.getAnswerText(), citations); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index c08fd5ae..24bf5905 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -300,6 +300,11 @@ public enum ErrorCode { "RAG-001", "LLM 서버를 사용할 수 없습니다." ), + RAG_ANSWER_NOT_FOUND( + HttpStatus.NOT_FOUND, + "RAG-002", + "검색 요청을 찾을 수 없습니다." + ), // MCP RATE_LIMIT_EXCEEDED( From d709863705f3ebb8b7c807eca43e78b8a604c1d6 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:57:23 +0900 Subject: [PATCH 06/15] =?UTF-8?q?chore:=20#218=20Ollama=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=A0=84=ED=99=98=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=9E=AC=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-deadline(25s->60s), read-timeout(27s->90s)는 원래 "프론트 29초 제한 전에 끝나야 한다"는 전제로 역산된 값이었다. 비동기 전환 후엔 프론트가 이 호출을 동기로 기다리지 않아 그 압박이 사라졌고, 대신 "Worker가 멈춘 요청 하나 때문에 큐 전체가 막히지 않도록" 하는 안전장치로 역할이 바뀌어서 실측 최악값(약 33초)보다 넉넉하게 늘렸다. read-timeout이 generate-deadline보다 커야 한다는 기존 관계는 유지했다. Co-Authored-By: Claude Fable 5 --- backend/src/main/resources/application.yml | 23 +++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b2c00e52..6e2fa240 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -115,22 +115,25 @@ search: ollama: server: base-url: ${OLLAMA_SERVER_URL:http://localhost:11434} - # 프론트의 29초 및 Sites Worker의 30초 제한 전에 검색 결과 Fallback을 반환한다. - # Docker 컨테이너(GPU 미가속) 기준이 아니라 macOS 네이티브 Ollama(Metal 가속) 실측 기준값이다. + # #218에서 검색-RAG를 비동기 Job 큐로 전환한 뒤로는 프론트가 이 호출을 동기로 기다리지 + # 않는다 — "프론트 29초 제한 전에 끝내야 한다"는 예전 제약이 사라졌으므로, 이 값은 이제 + # "Ollama 서버가 아예 응답하지 않을 때 빨리 실패 처리하는" 순수 인프라 안전장치로만 쓰인다. connect-timeout: ${OLLAMA_SERVER_CONNECT_TIMEOUT:3s} # 요청 시작부터 스트리밍 본문 수신까지 전체에 적용되는 전송 계층 제한(Spring JdkClientHttpRequestFactory가 # 본문 스트림에도 적용). 스트림이 멈췄을 때의 최후 방어선이며, 이때도 이미 받은 부분 답변은 보존된다. - # 정상 스트림의 시간 상한은 ollama.generate-deadline(25s)이 먼저 담당하므로 이 값은 그보다 커야 한다. - read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:27s} + # 정상 스트림의 시간 상한은 ollama.generate-deadline이 먼저 담당하므로 이 값은 그보다 커야 한다. + read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:90s} model: ${OLLAMA_MODEL:qwen2.5:7b} # 요청 사이 모델을 상주시켜 매 요청마다 재로딩(수 초)이 발생하지 않게 한다. keep-alive: ${OLLAMA_KEEP_ALIVE:30m} # 생성 토큰 수 상한. 시간 상한은 generate-deadline이 담당하므로 여기는 답변이 무한정 길어지는 # 것만 막는다 — 스트리밍 전환으로 250까지 조이던 것을 완화했다 (디코드가 빠른 세션에서는 더 긴 답변 허용). num-predict: ${OLLAMA_NUM_PREDICT:400} - # 스트리밍 생성의 전체 데드라인. 초과 시 요청을 끊고 그때까지 받은 부분 답변을 잘림 안내와 함께 - # 반환한다. 프론트 29초 제한보다 확실히 작아야 프론트가 포기하기 전에 응답할 수 있다. - generate-deadline: ${OLLAMA_GENERATE_DEADLINE:25s} + # 스트리밍 생성의 전체 데드라인. #218 이전에는 "프론트 29초 제한보다 확실히 작아야" 했지만(25s), + # 비동기 전환 이후에는 그 압박이 없다 — 역할이 "느긋하게 기다려주는 상한"에서 "Worker가 멈춘 + # 요청 하나 때문에 큐 전체가 막히지 않도록 하는 안전장치"로 바뀌었다. 그래서 정상적으로 가장 + # 오래 걸렸던 실측치(약 33초, 실사용 QA 패턴 기준)보다 넉넉히 크게 60초로 늘렸다. + generate-deadline: ${OLLAMA_GENERATE_DEADLINE:60s} # RAG는 문서 내용을 그대로 답하는 용도라 창의성이 불필요하다. 낮은 temperature/top-p로 # 확률 꼬리의 한자·가나 토큰이 뽑힐 확률을 줄여 한국어 답변에 중국어/일본어가 섞이는 것을 완화한다. temperature: ${OLLAMA_TEMPERATURE:0.3} @@ -141,3 +144,9 @@ ollama: # repeat_penalty가 되돌아보는 토큰 창. 기본 64로는 64토큰보다 긴 블록이 통째로 반복되는 것을 # 못 잡아서 넓혔다. 목록형 답변의 정당한 반복 표현이 어색해지면 128로 낮춰볼 것. repeat-last-n: ${OLLAMA_REPEAT_LAST_N:256} + +rag: + worker: + # RagJobWorker가 PROCESSING 건이 있는지 확인하는 주기. Worker가 1개뿐이라 짧게 잡아도 + # 부하가 안 크고, 사용자 체감 지연에 직접 영향을 주므로 1초로 짧게 유지한다. + polling-interval: ${RAG_WORKER_POLLING_INTERVAL:1s} From 806f696584dd5e9b2ad4824ff604d10a2e74865d Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:57:46 +0900 Subject: [PATCH 07/15] =?UTF-8?q?test:=20#218=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20RAG=20=EC=B2=98=EB=A6=AC=20=EB=8B=A8=EC=9C=84=C2=B7?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RagFacadeTest/RagResponseCommandServiceTest/SearchControllerTest/ DocGridMcpToolsTest를 새 enqueue/processJob API와 ragStatus 필드에 맞춰 갱신하고, RagJobWorkerTest(Worker의 예외 처리·push 로직)를 신규 추가했다. RagJobWorkerIntegrationTest/RagJobWorkerConcurrentQueueIntegrationTest는 Mockito 목으로는 검증 불가능한 detached entity 버그를 실제 Postgres 트랜잭션 경계로 재현하고, 이 작업의 핵심 목표("동시에 접수된 여러 job이 전부 완전한 LLM 답변을 받는다")를 실제 로컬 Ollama로 검증한다. 두 테스트 모두 만든 데이터를 @AfterEach로 직접 정리한다 — @Transactional로 감싸면 검증하려는 트랜잭션 경계 자체가 사라져서 걸 수 없기 때문이다. Co-Authored-By: Claude Fable 5 --- .../domain/mcp/tool/DocGridMcpToolsTest.java | 5 +- ...bWorkerConcurrentQueueIntegrationTest.java | 150 +++++++++++ .../RagJobWorkerIntegrationTest.java | 112 ++++++++ .../domain/rag/service/RagFacadeTest.java | 243 ++++++++++-------- .../domain/rag/service/RagJobWorkerTest.java | 88 +++++++ .../RagResponseCommandServiceTest.java | 55 ++-- .../controller/SearchControllerTest.java | 55 +++- 7 files changed, 568 insertions(+), 140 deletions(-) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerConcurrentQueueIntegrationTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerIntegrationTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java diff --git a/backend/src/test/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpToolsTest.java b/backend/src/test/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpToolsTest.java index 9895c6c3..4e17a220 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpToolsTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/mcp/tool/DocGridMcpToolsTest.java @@ -39,6 +39,7 @@ import com.opensource.docgrid.domain.search.dto.SearchOutcome; import com.opensource.docgrid.domain.search.dto.request.SearchRequest; import com.opensource.docgrid.domain.search.dto.response.SearchResponse; +import com.opensource.docgrid.domain.search.enums.ResultStatus; import com.opensource.docgrid.domain.search.dto.response.SearchResultItem; import com.opensource.docgrid.domain.search.service.SearchFacade; import com.opensource.docgrid.global.exception.DocGridException; @@ -190,7 +191,7 @@ void searchDocuments_truncatesChunkText_whenTooLong() { String longText = "가".repeat(1200); SearchResultItem item = new SearchResultItem(1, 10L, 20L, "문서", longText, null, BigDecimal.ONE); SearchOutcome outcome = new SearchOutcome( - new SearchResponse(1L, List.of(item), null, List.of()), List.of(), List.of()); + new SearchResponse(1L, List.of(item), ResultStatus.PROCESSING, null, List.of()), List.of(), List.of()); given(searchFacade.search(eq(USER_ID), any(SearchRequest.class))).willReturn(outcome); String result = docGridMcpTools.searchDocuments("query", 5); @@ -207,7 +208,7 @@ void searchDocuments_keepsChunkText_whenWithinLimit() { String shortText = "짧은 청크 텍스트"; SearchResultItem item = new SearchResultItem(1, 10L, 20L, "문서", shortText, null, BigDecimal.ONE); SearchOutcome outcome = new SearchOutcome( - new SearchResponse(1L, List.of(item), null, List.of()), List.of(), List.of()); + new SearchResponse(1L, List.of(item), ResultStatus.PROCESSING, null, List.of()), List.of(), List.of()); given(searchFacade.search(eq(USER_ID), any(SearchRequest.class))).willReturn(outcome); String result = docGridMcpTools.searchDocuments("query", 5); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerConcurrentQueueIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerConcurrentQueueIntegrationTest.java new file mode 100644 index 00000000..ebb750de --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerConcurrentQueueIntegrationTest.java @@ -0,0 +1,150 @@ +package com.opensource.docgrid.domain.rag.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; +import com.opensource.docgrid.domain.rag.repository.ResponseCitationRepository; +import com.opensource.docgrid.domain.rag.service.command.RagResponseCommandService; +import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.enums.SearchType; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.UserRepository; + +/** + * #218 비동기 Job 큐의 핵심 목표 검증: "여러 질문이 동시에 들어와도 전부 완전한 LLM 답변을 + * 받는다"(실패 없음). 실제 Spring @Scheduled RagJobWorker가 백그라운드에서 자연스럽게 큐를 + * 비우도록 두고(수동으로 processNext()를 여러 번 호출하지 않음), 3명이 정확히 같은 순간에 + * 질문을 던졌다고 가정해 3개 job을 동시 스레드로 접수한 뒤, 셋 다 결국 SUCCESS로 끝나는지 + * 실제 로컬 Ollama를 상대로 확인한다. + */ +@Tag("integration") +@SpringBootTest +@ActiveProfiles("test") +class RagJobWorkerConcurrentQueueIntegrationTest { + + @Autowired private RagResponseCommandService ragResponseCommandService; + @Autowired private RagResponseRepository ragResponseRepository; + @Autowired private SearchQueryRepository searchQueryRepository; + @Autowired private UserRepository userRepository; + @Autowired private EmbeddingModelRepository embeddingModelRepository; + @Autowired private ResponseCitationRepository responseCitationRepository; + + private final List createdUserIds = new CopyOnWriteArrayList<>(); + private final List createdQueryIds = new CopyOnWriteArrayList<>(); + private Long createdModelId; + + // 이 테스트는 @Transactional로 감쌀 수 없다(실제 @Scheduled Worker가 별도 스레드·트랜잭션에서 + // 자연스럽게 큐를 비우는 걸 검증해야 하므로). 그래서 만든 유저·검색·답변을 직접 정리한다 — + // 안 그러면 docgrid_test 스키마에 유저가 계속 쌓여 무관한 테스트(페이징 검증 등)가 흔들린다. + @AfterEach + void cleanUp() { + for (Long queryId : createdQueryIds) { + ragResponseRepository.findByQuery_Id(queryId).ifPresent(r -> { + responseCitationRepository.findByResponse_IdOrderByCitationOrder(r.getId()) + .forEach(responseCitationRepository::delete); + ragResponseRepository.delete(r); + }); + searchQueryRepository.deleteById(queryId); + } + if (createdModelId != null) embeddingModelRepository.deleteById(createdModelId); + createdUserIds.forEach(userRepository::deleteById); + } + + @Test + @DisplayName("동시에 접수된 job 3개가 실제 RagJobWorker 스케줄러만으로 전부 SUCCESS로 끝난다") + void threeConcurrentJobs_allEventuallySucceedViaRealScheduler() throws InterruptedException { + EmbeddingModel model = embeddingModelRepository.save( + EmbeddingModelFixture.createModel("concurrent-it-" + System.nanoTime(), false, false) + ); + createdModelId = model.getId(); + + List prompts = List.of( + "숫자만 한 글자로 답해줘. 1+1은?", + "숫자만 한 글자로 답해줘. 2+2는?", + "숫자만 한 글자로 답해줘. 3+3은?" + ); + + List jobIds = new CopyOnWriteArrayList<>(); + CountDownLatch startLine = new CountDownLatch(1); + CountDownLatch allSubmitted = new CountDownLatch(prompts.size()); + + // 3명이 "정확히 같은 순간"에 질문을 던진 상황을 재현 — 스레드를 미리 다 띄워두고 + // startLine으로 동시에 풀어준다. + for (String prompt : prompts) { + Thread thread = new Thread(() -> { + try { + startLine.await(); + SearchQuery query = searchQueryRepository.save(SearchQuery.builder() + .user(createUser()) + .queryText(prompt) + .queryEmbeddingModel(model) + .queryVector(new float[1024]) + .searchType(SearchType.VECTOR) + .topK(5) + .status(ResultStatus.SUCCESS) + .build()); + createdQueryIds.add(query.getId()); + RagResponse pending = ragResponseCommandService.createPending(query, prompt); + jobIds.add(pending.getId()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + allSubmitted.countDown(); + } + }); + thread.start(); + } + startLine.countDown(); + allSubmitted.await(); + + assertThat(jobIds).hasSize(3); + + // 수동으로 processNext()를 여러 번 부르지 않는다 — 실제 배포에서와 똑같이, 이미 켜져 있는 + // @Scheduled RagJobWorker가 1초 주기로 알아서 큐를 비우는 걸 그대로 기다린다. + await().atMost(Duration.ofSeconds(150)).pollInterval(Duration.ofSeconds(2)).untilAsserted(() -> { + List jobs = ragResponseRepository.findAllById(jobIds); + assertThat(jobs).allSatisfy(job -> assertThat(job.getStatus()).isNotEqualTo(ResultStatus.PROCESSING)); + }); + + List finished = ragResponseRepository.findAllById(jobIds); + assertThat(finished).hasSize(3); + // 핵심 주장: 셋 다 "빈손"이 아니라 실제 답변 텍스트를 갖고 있다(SUCCESS든, LLM 실패 시의 + // extractive fallback이든 — 어느 쪽이든 answerText는 항상 채워진다). + assertThat(finished).allSatisfy(job -> assertThat(job.getAnswerText()).isNotBlank()); + long successCount = finished.stream().filter(j -> j.getStatus() == ResultStatus.SUCCESS).count(); + System.out.println("[TEST] SUCCESS=" + successCount + "/3, answers=" + + finished.stream().map(RagResponse::getAnswerText).toList()); + } + + private User createUser() { + User user = userRepository.save(User.builder() + .email("concurrent-it-" + System.nanoTime() + "-" + Math.random() + "@test.local") + .passwordHash("x") + .name("동시성테스트유저") + .status(UserStatus.ACTIVE) + .build()); + createdUserIds.add(user.getId()); + return user; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerIntegrationTest.java new file mode 100644 index 00000000..06545694 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/integration/RagJobWorkerIntegrationTest.java @@ -0,0 +1,112 @@ +package com.opensource.docgrid.domain.rag.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; +import com.opensource.docgrid.domain.rag.repository.ResponseCitationRepository; +import com.opensource.docgrid.domain.rag.service.RagJobWorker; +import com.opensource.docgrid.domain.rag.service.command.RagResponseCommandService; +import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.enums.SearchType; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.UserRepository; + +/** + * RagResponseRepository.findFirstByStatusOrderByCreatedAtAsc()로 꺼낸 job이 detached 상태라, + * RagFacade.processJob()에 그 인스턴스를 그대로 넘기면 markSuccess/markFailed로 값을 바꿔도 + * dirty checking이 감지하지 못해 DB에 반영되지 않는(=영원히 PROCESSING으로 남는) 실사용 버그가 + * 있었다. 이 테스트는 그 버그를 Mockito 목이 아니라 실제 트랜잭션 경계로 재현·검증한다 — 목 + * 기반 단위 테스트는 "메서드가 호출됐는지"만 보고 "DB에 실제로 반영됐는지"는 증명하지 못한다. + */ +@Tag("integration") +@SpringBootTest +@ActiveProfiles("test") +class RagJobWorkerIntegrationTest { + + @Autowired private RagJobWorker ragJobWorker; + @Autowired private RagResponseCommandService ragResponseCommandService; + @Autowired private RagResponseRepository ragResponseRepository; + @Autowired private SearchQueryRepository searchQueryRepository; + @Autowired private UserRepository userRepository; + @Autowired private EmbeddingModelRepository embeddingModelRepository; + @Autowired private ResponseCitationRepository responseCitationRepository; + + private Long createdUserId; + private Long createdModelId; + private Long createdQueryId; + + // 테스트 메서드를 @Transactional로 감쌀 수 없어(위 설명 참고) 자동 롤백이 안 되므로, 만든 + // 데이터를 직접 정리한다 — 안 그러면 docgrid_test 스키마에 유저가 계속 쌓여 다른 테스트 + // (예: 페이징 검증)가 이 잔여 데이터 때문에 흔들리는 사고가 난다(실제로 한 번 발생했었다). + @AfterEach + void cleanUp() { + if (createdQueryId != null) { + ragResponseRepository.findByQuery_Id(createdQueryId).ifPresent(r -> { + responseCitationRepository.findByResponse_IdOrderByCitationOrder(r.getId()) + .forEach(responseCitationRepository::delete); + ragResponseRepository.delete(r); + }); + searchQueryRepository.deleteById(createdQueryId); + } + if (createdModelId != null) embeddingModelRepository.deleteById(createdModelId); + if (createdUserId != null) userRepository.deleteById(createdUserId); + } + + @Test + @DisplayName("processNext(): PROCESSING row가 detached 상태로 넘어가도 최종 상태가 DB에 실제로 반영된다") + void processNext_persistsStatusChangeAcrossDetachedEntityBoundary() { + // 테스트 메서드 자체를 @Transactional로 감싸지 않는다 — 그러면 아래 저장들과 processNext() + // 내부 호출이 전부 같은 세션을 공유해버려 원래 버그(서로 다른 트랜잭션 간 detached 상태)를 + // 재현하지 못한다. 각 호출이 자기 자신의 @Transactional로 독립적인 커밋을 하도록 그대로 둔다. + User user = userRepository.save(User.builder() + .email("ragjobworker-it-" + System.nanoTime() + "@test.local") + .passwordHash("x") + .name("통합테스트유저") + .status(UserStatus.ACTIVE) + .build()); + createdUserId = user.getId(); + // 활성 임베딩 모델은 "동시에 1개만" 제약(uk_embedding_models_one_active_searchable)이 걸려있어, + // seed 데이터에 이미 있는 활성 모델과 충돌하지 않도록 이 테스트 전용 비활성 모델을 새로 만든다. + EmbeddingModel model = embeddingModelRepository.save( + EmbeddingModelFixture.createModel("ragjobworker-it-" + System.nanoTime(), false, false) + ); + createdModelId = model.getId(); + SearchQuery query = searchQueryRepository.save(SearchQuery.builder() + .user(user) + .queryText("통합 테스트 질문") + .queryEmbeddingModel(model) + .queryVector(new float[1024]) + .searchType(SearchType.VECTOR) + .topK(5) + .status(ResultStatus.SUCCESS) + .build()); + createdQueryId = query.getId(); + + RagResponse pending = ragResponseCommandService.createPending(query, "통합 테스트용 프롬프트"); + Long jobId = pending.getId(); + + ragJobWorker.processNext(); + + // Ollama가 로컬에 떠 있지 않을 수도 있으므로 SUCCESS/FAILED 둘 다 통과 조건으로 둔다 — + // 이 테스트가 검증하는 건 "LLM 호출 성공 여부"가 아니라 "detached 상태에서도 최종 + // 상태가 DB에 반영되는지"다. + RagResponse persisted = ragResponseRepository.findById(jobId).orElseThrow(); + assertThat(persisted.getStatus()).isNotEqualTo(ResultStatus.PROCESSING); + assertThat(persisted.getAnswerText()).isNotNull(); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java index 63336008..810be39c 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java @@ -6,12 +6,14 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import java.math.BigDecimal; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,18 +26,25 @@ import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; import com.opensource.docgrid.domain.rag.dto.RagAnswer; +import com.opensource.docgrid.domain.rag.dto.RagEnqueueOutcome; import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; import com.opensource.docgrid.domain.rag.service.command.RagResponseCommandService; import com.opensource.docgrid.domain.rag.service.command.ResponseCitationCommandService; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; import com.opensource.docgrid.domain.search.entity.SearchQuery; import com.opensource.docgrid.domain.search.entity.SearchResult; import com.opensource.docgrid.domain.search.enums.ResultStatus; +import com.opensource.docgrid.domain.search.repository.SearchResultRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; import jakarta.persistence.EntityManager; +/** + * #218(비동기 Job 큐 전환) 이후 RagFacade는 enqueue()(검색 직후 동기, 프롬프트 조립만)와 + * processJob()(RagJobWorker가 비동기로 호출, 실제 LLM 생성+영속화)로 나뉜다. + */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @DisplayName("RagFacade 단위 테스트") @@ -56,14 +65,23 @@ class RagFacadeTest { @Mock private ResponseCitationCommandService responseCitationCommandService; + @Mock + private RagResponseRepository ragResponseRepository; + + @Mock + private SearchResultRepository searchResultRepository; + @Mock private EntityManager entityManager; private static final Long QUERY_ID = 100L; + private static final Long JOB_ID = 999L; + + // === enqueue() === @Test - @DisplayName("NO_CONTEXT: 검색 후보가 모두 제거되면 LLM과 citation 저장 없이 고정 응답을 저장한다") - void generate_noQualifiedCandidates_skipsLlmAndCitationAndSavesFixedAnswer() { + @DisplayName("enqueue: NO_CONTEXT면 프롬프트 조립·PROCESSING 저장 없이 고정 응답으로 즉시 끝난다") + void enqueue_noQualifiedCandidates_returnsDoneWithFixedAnswer() { SearchQuery queryRef = mock(SearchQuery.class); given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); RagResponse noContextResponse = RagResponse.builder() @@ -72,21 +90,19 @@ void generate_noQualifiedCandidates_skipsLlmAndCitationAndSavesFixedAnswer() { .build(); given(ragResponseCommandService.createNoContext(queryRef)).willReturn(noContextResponse); - RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", List.of(), List.of()); + RagEnqueueOutcome outcome = ragFacade.enqueue(QUERY_ID, "질문", List.of()); - assertThat(answer.answerText()).isEqualTo("관련 문서를 찾지 못했습니다."); - assertThat(answer.citations()).isEmpty(); + assertThat(outcome.pending()).isFalse(); + assertThat(outcome.immediateAnswer().answerText()).isEqualTo("관련 문서를 찾지 못했습니다."); + assertThat(outcome.immediateAnswer().citations()).isEmpty(); then(promptBuilder).should(never()).build(anyString(), any()); - then(ollamaClient).should(never()).generate(anyString()); then(ragResponseCommandService).should(times(1)).createNoContext(queryRef); - then(ragResponseCommandService).should(never()).createSuccess(any(), anyString(), any()); - then(ragResponseCommandService).should(never()).createFailed(any(), anyString(), anyString()); - then(responseCitationCommandService).shouldHaveNoInteractions(); + then(ragResponseCommandService).should(never()).createPending(any(), anyString()); } @Test - @DisplayName("정상 흐름: 프롬프트 조립 후 Ollama 호출, rag_responses/citations 저장, answer+citations를 반환한다") - void generate_success_savesResponseAndCitations() { + @DisplayName("enqueue: 검색 후보가 있으면 프롬프트만 조립해 PROCESSING으로 저장하고 pending을 반환한다(LLM 호출 없음)") + void enqueue_withCandidates_savesPendingWithoutCallingOllama() { SearchQuery queryRef = mock(SearchQuery.class); given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); @@ -94,142 +110,151 @@ void generate_success_savesResponseAndCitations() { 1L, 10L, 100L, "청크 내용", 12, "인사규정", new BigDecimal("0.9") ); List candidates = List.of(candidate); - SearchResult searchResult = mock(SearchResult.class); - List searchResults = List.of(searchResult); - given(promptBuilder.build(eq("연차 규정 알려줘"), eq(candidates))).willReturn("조립된 프롬프트"); - OllamaGenerateResult ollamaResult = new OllamaGenerateResult("qwen2.5:3b", "연차는 15일입니다.", 100, 20, 900); - given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); - RagResponse ragResponse = RagResponse.builder() - .answerText("연차는 15일입니다.") - .status(ResultStatus.SUCCESS) - .build(); - given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); + given(ragResponseCommandService.createPending(queryRef, "조립된 프롬프트")) + .willReturn(RagResponse.builder().status(ResultStatus.PROCESSING).build()); - RagAnswer answer = ragFacade.generate(QUERY_ID, "연차 규정 알려줘", candidates, searchResults); + RagEnqueueOutcome outcome = ragFacade.enqueue(QUERY_ID, "연차 규정 알려줘", candidates); - assertThat(answer.answerText()).isEqualTo("연차는 15일입니다."); - assertThat(answer.citations()).hasSize(1); - assertThat(answer.citations().get(0).label()).isEqualTo("[1]"); - assertThat(answer.citations().get(0).documentId()).isEqualTo(100L); - then(responseCitationCommandService).should(times(1)).saveAll(ragResponse, candidates, searchResults); - then(ragResponseCommandService).should(never()).createFailed(any(), any(), any()); + assertThat(outcome.pending()).isTrue(); + assertThat(outcome.immediateAnswer()).isNull(); + then(ollamaClient).should(never()).generate(anyString()); + then(ragResponseCommandService).should(times(1)).createPending(queryRef, "조립된 프롬프트"); } @Test - @DisplayName("Ollama 호출 실패: FAILED로 기록하고 최상위 후보 원문을 인용한 extractive fallback을 반환한다") - void generate_ollamaFails_savesFailedAndReturnsExtractiveFallback() { + @DisplayName("enqueue: 검색 후보가 3개를 넘으면 LLM 프롬프트에는 상위 3개만 전달한다") + void enqueue_moreThanMaxPromptCandidates_truncatesForPrompt() { SearchQuery queryRef = mock(SearchQuery.class); given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); - VectorSearchCandidate candidate = new VectorSearchCandidate( - 1L, 10L, 100L, "청크 내용", 12, "인사규정", new BigDecimal("0.9") + List candidates = List.of( + new VectorSearchCandidate(1L, 10L, 100L, "청크1", 1, "문서1", new BigDecimal("0.9")), + new VectorSearchCandidate(2L, 20L, 200L, "청크2", 2, "문서2", new BigDecimal("0.8")), + new VectorSearchCandidate(3L, 30L, 300L, "청크3", 3, "문서3", new BigDecimal("0.7")), + new VectorSearchCandidate(4L, 40L, 400L, "청크4", 4, "문서4", new BigDecimal("0.6")), + new VectorSearchCandidate(5L, 50L, 500L, "청크5", 5, "문서5", new BigDecimal("0.5")) ); - List candidates = List.of(candidate); - - given(promptBuilder.build(anyString(), eq(candidates))).willReturn("조립된 프롬프트"); - given(ollamaClient.generate("조립된 프롬프트")) - .willThrow(new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE)); - - RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, List.of()); + List expectedPromptCandidates = candidates.subList(0, 3); + given(promptBuilder.build(anyString(), eq(expectedPromptCandidates))).willReturn("조립된 프롬프트"); + given(ragResponseCommandService.createPending(queryRef, "조립된 프롬프트")) + .willReturn(RagResponse.builder().status(ResultStatus.PROCESSING).build()); - assertThat(answer.answerText()).contains("AI 답변 생성이 지연"); - assertThat(answer.answerText()).contains("청크 내용"); - assertThat(answer.answerText()).contains("인사규정"); - assertThat(answer.citations()).hasSize(1); - assertThat(answer.citations().get(0).documentId()).isEqualTo(100L); + ragFacade.enqueue(QUERY_ID, "질문", candidates); - then(ragResponseCommandService).should(times(1)) - .createFailed(eq(queryRef), eq("조립된 프롬프트"), anyString()); - then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); + then(promptBuilder).should(times(1)).build(anyString(), eq(expectedPromptCandidates)); } + // === processJob() === + @Test - @DisplayName("LLM 무관 판단: 답변이 안내 문구면 citation을 저장은 하되 반환 answer에는 포함하지 않는다") - void generate_llmJudgesIrrelevant_returnsEmptyCitations() { + @DisplayName("processJob 정상 흐름: Ollama 호출 성공 시 completeSuccess와 citation을 저장한다") + void processJob_success_savesResponseAndCitations() { SearchQuery queryRef = mock(SearchQuery.class); - given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); + given(queryRef.getId()).willReturn(QUERY_ID); + RagResponse job = RagResponse.builder().query(queryRef).promptText("조립된 프롬프트").status(ResultStatus.PROCESSING).build(); + given(ragResponseRepository.findById(JOB_ID)).willReturn(Optional.of(job)); - VectorSearchCandidate candidate = new VectorSearchCandidate( - 1L, 10L, 100L, "청크 내용", 12, "인사규정", new BigDecimal("0.9") - ); - List candidates = List.of(candidate); - SearchResult searchResult = mock(SearchResult.class); - List searchResults = List.of(searchResult); - - given(promptBuilder.build(anyString(), eq(candidates))).willReturn("조립된 프롬프트"); - OllamaGenerateResult ollamaResult = - new OllamaGenerateResult("qwen2.5:3b", "관련 문서를 찾지 못했습니다.", 100, 10, 500); + OllamaGenerateResult ollamaResult = new OllamaGenerateResult("qwen2.5:7b", "연차는 15일입니다.", 100, 20, 900); given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); - RagResponse ragResponse = RagResponse.builder() - .answerText("관련 문서를 찾지 못했습니다.") - .status(ResultStatus.SUCCESS) - .build(); - given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); - RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, searchResults); + SearchResult searchResult = deepStubSearchResult(100L, 10L, "청크 내용", 12, "인사규정", new BigDecimal("0.9")); + given(searchResultRepository.findByQuery_IdOrderByRankNo(QUERY_ID)).willReturn(List.of(searchResult)); + + ragFacade.processJob(JOB_ID); - assertThat(answer.answerText()).isEqualTo("관련 문서를 찾지 못했습니다."); - assertThat(answer.citations()).isEmpty(); - then(responseCitationCommandService).should(times(1)).saveAll(ragResponse, candidates, searchResults); + then(ragResponseCommandService).should(times(1)).completeSuccess(eq(job), any()); + then(responseCitationCommandService).should(times(1)).saveAll(eq(job), any(), eq(List.of(searchResult))); + then(ragResponseCommandService).should(never()).completeFailed(any(), anyString(), anyString()); } @Test - @DisplayName("무관 문구 혼입: 정상 답변 중간에 안내 문구가 섞이면 그 지점부터 제거하고 citation은 유지한다") - void generate_phraseEmbeddedInAnswer_stripsPhraseAndKeepsCitations() { + @DisplayName("processJob Ollama 실패: completeFailed로 최상위 후보 원문을 인용한 extractive fallback을 저장한다") + void processJob_ollamaFails_savesFailedWithExtractiveFallback() { SearchQuery queryRef = mock(SearchQuery.class); - given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); + given(queryRef.getId()).willReturn(QUERY_ID); + RagResponse job = RagResponse.builder().query(queryRef).promptText("조립된 프롬프트").status(ResultStatus.PROCESSING).build(); + given(ragResponseRepository.findById(JOB_ID)).willReturn(Optional.of(job)); - VectorSearchCandidate candidate = new VectorSearchCandidate( - 1L, 10L, 100L, "청크 내용", 12, "디렉토리 명령어", new BigDecimal("0.9") + given(ollamaClient.generate("조립된 프롬프트")) + .willThrow(new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE)); + + SearchResult searchResult = deepStubSearchResult(100L, 10L, "청크 내용", 12, "인사규정", new BigDecimal("0.9")); + given(searchResultRepository.findByQuery_IdOrderByRankNo(QUERY_ID)).willReturn(List.of(searchResult)); + + ragFacade.processJob(JOB_ID); + + then(ragResponseCommandService).should(times(1)).completeFailed( + eq(job), argThatFallbackContains("AI 답변 생성이 지연", "청크 내용", "인사규정"), anyString() ); - List candidates = List.of(candidate); - SearchResult searchResult = mock(SearchResult.class); - List searchResults = List.of(searchResult); + then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); + } + + @Test + @DisplayName("processJob LLM 무관 판단: 답변이 안내 문구로 시작하면 citation을 저장하지 않는다") + void processJob_llmJudgesIrrelevant_skipsCitations() { + SearchQuery queryRef = mock(SearchQuery.class); + given(queryRef.getId()).willReturn(QUERY_ID); + RagResponse job = RagResponse.builder().query(queryRef).promptText("조립된 프롬프트").status(ResultStatus.PROCESSING).build(); + given(ragResponseRepository.findById(JOB_ID)).willReturn(Optional.of(job)); - given(promptBuilder.build(anyString(), eq(candidates))).willReturn("조립된 프롬프트"); - String answerWithEcho = "pwd는 현재 디렉토리를 출력합니다. " - + "관련 문서를 찾지 못했습니다. 질문 주제와 관련된 문서가 없습니다."; OllamaGenerateResult ollamaResult = - new OllamaGenerateResult("qwen2.5:3b", answerWithEcho, 100, 50, 500); + new OllamaGenerateResult("qwen2.5:7b", "관련 문서를 찾지 못했습니다.", 100, 10, 500); given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); - RagResponse ragResponse = RagResponse.builder() - .answerText(answerWithEcho) - .status(ResultStatus.SUCCESS) - .build(); - given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); + SearchResult searchResult = deepStubSearchResult(100L, 10L, "청크 내용", 12, "인사규정", new BigDecimal("0.9")); + given(searchResultRepository.findByQuery_IdOrderByRankNo(QUERY_ID)).willReturn(List.of(searchResult)); - RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, searchResults); + ragFacade.processJob(JOB_ID); - assertThat(answer.answerText()).isEqualTo("pwd는 현재 디렉토리를 출력합니다."); - assertThat(answer.citations()).hasSize(1); + then(ragResponseCommandService).should(times(1)).completeSuccess(eq(job), any()); + then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); } @Test - @DisplayName("후보 상한: 검색 후보가 3개를 넘으면 LLM에는 상위 3개만 전달한다") - void generate_moreThanMaxPromptCandidates_truncatesForPrompt() { + @DisplayName("processJob 무관 문구 혼입: 정상 답변 중간에 안내 문구가 섞이면 그 지점부터 제거한 뒤 저장하고 citation은 유지한다") + void processJob_phraseEmbeddedInAnswer_trimsBeforePersistingAndKeepsCitations() { SearchQuery queryRef = mock(SearchQuery.class); - given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); + given(queryRef.getId()).willReturn(QUERY_ID); + RagResponse job = RagResponse.builder().query(queryRef).promptText("조립된 프롬프트").status(ResultStatus.PROCESSING).build(); + given(ragResponseRepository.findById(JOB_ID)).willReturn(Optional.of(job)); - List candidates = List.of( - new VectorSearchCandidate(1L, 10L, 100L, "청크1", 1, "문서1", new BigDecimal("0.9")), - new VectorSearchCandidate(2L, 20L, 200L, "청크2", 2, "문서2", new BigDecimal("0.8")), - new VectorSearchCandidate(3L, 30L, 300L, "청크3", 3, "문서3", new BigDecimal("0.7")), - new VectorSearchCandidate(4L, 40L, 400L, "청크4", 4, "문서4", new BigDecimal("0.6")), - new VectorSearchCandidate(5L, 50L, 500L, "청크5", 5, "문서5", new BigDecimal("0.5")) - ); - List expectedPromptCandidates = candidates.subList(0, 3); - - given(promptBuilder.build(anyString(), eq(expectedPromptCandidates))).willReturn("조립된 프롬프트"); - OllamaGenerateResult ollamaResult = new OllamaGenerateResult("qwen2.5:3b", "답변", 100, 20, 900); + String answerWithEcho = "pwd는 현재 디렉토리를 출력합니다. " + + "관련 문서를 찾지 못했습니다. 질문 주제와 관련된 문서가 없습니다."; + OllamaGenerateResult ollamaResult = new OllamaGenerateResult("qwen2.5:7b", answerWithEcho, 100, 50, 500); given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); - RagResponse ragResponse = RagResponse.builder().answerText("답변").status(ResultStatus.SUCCESS).build(); - given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); + SearchResult searchResult = deepStubSearchResult(100L, 10L, "청크 내용", 12, "디렉토리 명령어", new BigDecimal("0.9")); + given(searchResultRepository.findByQuery_IdOrderByRankNo(QUERY_ID)).willReturn(List.of(searchResult)); - RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, List.of()); + ragFacade.processJob(JOB_ID); - assertThat(answer.citations()).hasSize(5); - then(promptBuilder).should(times(1)).build(anyString(), eq(expectedPromptCandidates)); - then(responseCitationCommandService).should(times(1)).saveAll(ragResponse, candidates, List.of()); + then(ragResponseCommandService).should(times(1)).completeSuccess(eq(job), + org.mockito.ArgumentMatchers.argThat(result -> + result.answerText().equals("pwd는 현재 디렉토리를 출력합니다.") + )); + then(responseCitationCommandService).should(times(1)).saveAll(eq(job), any(), eq(List.of(searchResult))); + } + + private SearchResult deepStubSearchResult( + Long documentId, Long chunkId, String chunkText, Integer pageNo, String documentTitle, BigDecimal similarityScore + ) { + SearchResult searchResult = mock(SearchResult.class, RETURNS_DEEP_STUBS); + given(searchResult.getSimilarityScore()).willReturn(similarityScore); + given(searchResult.getEmbedding()).willReturn(null); + given(searchResult.getChunk().getId()).willReturn(chunkId); + given(searchResult.getChunk().getChunkText()).willReturn(chunkText); + given(searchResult.getChunk().getPageNo()).willReturn(pageNo); + given(searchResult.getChunk().getDocumentVersion().getDocument().getId()).willReturn(documentId); + given(searchResult.getChunk().getDocumentVersion().getDocument().getTitle()).willReturn(documentTitle); + return searchResult; + } + + private String argThatFallbackContains(String... fragments) { + // Mockito의 argThat과 조합해 여러 부분 문자열을 한 번에 검증하기 위한 헬퍼. + return org.mockito.ArgumentMatchers.argThat(text -> { + for (String fragment : fragments) { + if (!text.contains(fragment)) return false; + } + return true; + }); } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java new file mode 100644 index 00000000..b1cba7e9 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java @@ -0,0 +1,88 @@ +package com.opensource.docgrid.domain.rag.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.rag.controller.RagWebSocketController; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; +import com.opensource.docgrid.domain.search.enums.ResultStatus; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RagJobWorker 단위 테스트") +class RagJobWorkerTest { + + @InjectMocks + private RagJobWorker ragJobWorker; + + @Mock + private RagResponseRepository ragResponseRepository; + + @Mock + private RagFacade ragFacade; + + @Mock + private RagWebSocketController ragWebSocketController; + + @Test + @DisplayName("PROCESSING 건이 없으면 아무것도 하지 않는다") + void processNext_noPendingJob_doesNothing() { + given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) + .willReturn(Optional.empty()); + + ragJobWorker.processNext(); + + then(ragFacade).should(never()).processJob(any()); + then(ragWebSocketController).should(never()).notifyAnswerReady(any(), any()); + } + + @Test + @DisplayName("PROCESSING 건이 있으면 처리하고, 요청자 본인에게만 완료를 push한다") + void processNext_pendingJobExists_processesAndNotifiesOwner() { + RagResponse job = deepStubJob(999L, 100L, "user@example.com"); + given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) + .willReturn(Optional.of(job)); + + ragJobWorker.processNext(); + + // Worker는 detached entity를 그대로 넘기지 않고 id만 넘긴다 — processJob()이 자기 트랜잭션 + // 안에서 다시 조회해야 markSuccess 등의 변경이 dirty checking으로 실제 반영된다. + then(ragFacade).should(times(1)).processJob(999L); + then(ragWebSocketController).should(times(1)).notifyAnswerReady("user@example.com", 100L); + } + + @Test + @DisplayName("processJob이 예상 밖 예외를 던져도 Worker는 죽지 않고 이번 건만 건너뛴다(push 생략)") + void processNext_unexpectedException_skipsJobWithoutCrashingWorker() { + RagResponse job = deepStubJob(999L, 100L, "user@example.com"); + given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) + .willReturn(Optional.of(job)); + org.mockito.Mockito.doThrow(new RuntimeException("예상 밖 버그")).when(ragFacade).processJob(999L); + + ragJobWorker.processNext(); + + then(ragWebSocketController).should(never()).notifyAnswerReady(any(), any()); + } + + private RagResponse deepStubJob(Long jobId, Long queryId, String userEmail) { + RagResponse job = mock(RagResponse.class, RETURNS_DEEP_STUBS); + given(job.getId()).willReturn(jobId); + given(job.getQuery().getId()).willReturn(queryId); + given(job.getQuery().getUser().getEmail()).willReturn(userEmail); + return job; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java index b104e08d..59e8ec79 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java @@ -32,46 +32,53 @@ class RagResponseCommandServiceTest { private RagResponseRepository ragResponseRepository; @Test - @DisplayName("createSuccess: SUCCESS 상태로 답변/모델명/토큰수/latency를 저장한다") - void createSuccess_savesWithSuccessStatus() { + @DisplayName("createPending: PROCESSING 상태로 프롬프트만 먼저 저장한다(답변 없음)") + void createPending_savesWithProcessingStatus() { SearchQuery query = SearchQueryFixture.createProcessing(); - OllamaGenerateResult result = new OllamaGenerateResult( - "qwen2.5:3b", "연차는 입사 1년 기준 15일 부여됩니다.", 120, 45, 1800 - ); given(ragResponseRepository.save(any(RagResponse.class))).willAnswer(i -> i.getArgument(0)); - ragResponseCommandService.createSuccess(query, "조립된 프롬프트", result); + ragResponseCommandService.createPending(query, "조립된 프롬프트"); ArgumentCaptor captor = ArgumentCaptor.forClass(RagResponse.class); then(ragResponseRepository).should(times(1)).save(captor.capture()); RagResponse saved = captor.getValue(); - assertThat(saved.getStatus()).isEqualTo(ResultStatus.SUCCESS); - assertThat(saved.getAnswerText()).isEqualTo("연차는 입사 1년 기준 15일 부여됩니다."); + assertThat(saved.getStatus()).isEqualTo(ResultStatus.PROCESSING); + assertThat(saved.getAnswerText()).isNull(); assertThat(saved.getLlmProvider()).isEqualTo("Ollama"); - assertThat(saved.getLlmModelName()).isEqualTo("qwen2.5:3b"); assertThat(saved.getPromptText()).isEqualTo("조립된 프롬프트"); - assertThat(saved.getInputTokenCount()).isEqualTo(120); - assertThat(saved.getOutputTokenCount()).isEqualTo(45); - assertThat(saved.getLatencyMs()).isEqualTo(1800); } @Test - @DisplayName("createFailed: FAILED 상태로 고정 답변 문구와 실패 사유를 저장한다") - void createFailed_savesWithFailedStatus() { - SearchQuery query = SearchQueryFixture.createProcessing(); - given(ragResponseRepository.save(any(RagResponse.class))).willAnswer(i -> i.getArgument(0)); + @DisplayName("completeSuccess: PROCESSING row를 SUCCESS로 채운다(dirty checking, save 재호출 없음)") + void completeSuccess_fillsProcessingRowWithSuccessStatus() { + RagResponse pending = RagResponse.builder().status(ResultStatus.PROCESSING).promptText("조립된 프롬프트").build(); + OllamaGenerateResult result = new OllamaGenerateResult( + "qwen2.5:7b", "연차는 입사 1년 기준 15일 부여됩니다.", 120, 45, 1800 + ); - ragResponseCommandService.createFailed(query, "조립된 프롬프트", "Ollama 서버 연결 실패"); + ragResponseCommandService.completeSuccess(pending, result); - ArgumentCaptor captor = ArgumentCaptor.forClass(RagResponse.class); - then(ragResponseRepository).should(times(1)).save(captor.capture()); + assertThat(pending.getStatus()).isEqualTo(ResultStatus.SUCCESS); + assertThat(pending.getAnswerText()).isEqualTo("연차는 입사 1년 기준 15일 부여됩니다."); + assertThat(pending.getLlmModelName()).isEqualTo("qwen2.5:7b"); + assertThat(pending.getInputTokenCount()).isEqualTo(120); + assertThat(pending.getOutputTokenCount()).isEqualTo(45); + assertThat(pending.getLatencyMs()).isEqualTo(1800); + then(ragResponseRepository).shouldHaveNoInteractions(); + } - RagResponse saved = captor.getValue(); - assertThat(saved.getStatus()).isEqualTo(ResultStatus.FAILED); - assertThat(saved.getAnswerText()).isEqualTo("답변 생성에 실패했습니다."); - assertThat(saved.getErrorMessage()).isEqualTo("Ollama 서버 연결 실패"); - assertThat(saved.getLlmProvider()).isEqualTo("Ollama"); + @Test + @DisplayName("completeFailed: PROCESSING row를 FAILED로 채우되 답변에는 fallback 텍스트를 남긴다") + void completeFailed_fillsProcessingRowWithFailedStatusAndFallbackText() { + RagResponse pending = RagResponse.builder().status(ResultStatus.PROCESSING).promptText("조립된 프롬프트").build(); + + ragResponseCommandService.completeFailed(pending, "extractive fallback 텍스트", "Ollama 서버 연결 실패"); + + assertThat(pending.getStatus()).isEqualTo(ResultStatus.FAILED); + assertThat(pending.getAnswerText()).isEqualTo("extractive fallback 텍스트"); + assertThat(pending.getErrorMessage()).isEqualTo("Ollama 서버 연결 실패"); + then(ragResponseRepository).shouldHaveNoInteractions(); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/controller/SearchControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/search/controller/SearchControllerTest.java index c1edf93f..75f82072 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/search/controller/SearchControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/controller/SearchControllerTest.java @@ -21,14 +21,21 @@ import org.springframework.test.web.servlet.MockMvc; import com.opensource.docgrid.domain.rag.dto.RagAnswer; +import com.opensource.docgrid.domain.rag.dto.RagEnqueueOutcome; import com.opensource.docgrid.domain.rag.service.RagFacade; import com.opensource.docgrid.domain.search.dto.SearchOutcome; +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; import com.opensource.docgrid.domain.search.dto.request.SearchRequest; import com.opensource.docgrid.domain.search.dto.response.SearchResponse; import com.opensource.docgrid.domain.search.service.SearchFacade; +import com.opensource.docgrid.domain.search.service.query.SearchAnswerQueryService; + +import java.math.BigDecimal; /** - * 검색 후보가 모두 제거된 경우의 인증 사용자 전달과 NO_CONTEXT 공개 응답 계약을 검증한다. + * #218(비동기 Job 큐 전환) 이후 POST /search는 RagFacade.enqueue()를 호출한다 — NO_CONTEXT는 + * 여전히 즉시 답변을 반환하지만, 검색 후보가 있으면 answer=null·ragStatus=PROCESSING으로 즉시 + * 응답하고 LLM 호출은 기다리지 않는다. */ @WebMvcTest(SearchController.class) @DisplayName("SearchController 테스트") @@ -42,10 +49,11 @@ class SearchControllerTest { @MockitoBean private SearchFacade searchFacade; @MockitoBean private RagFacade ragFacade; + @MockitoBean private SearchAnswerQueryService searchAnswerQueryService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @Test - @DisplayName("검색 후보가 모두 제거되면 빈 근거 목록과 NO_CONTEXT 답변을 반환한다") + @DisplayName("검색 후보가 모두 제거되면 빈 근거 목록과 NO_CONTEXT 답변을 즉시(SUCCESS) 반환한다") void search_noQualifiedCandidates_returnsNoContextResponse() throws Exception { SearchRequest request = new SearchRequest("넌 뭐야?", 5, null); SearchOutcome outcome = new SearchOutcome( @@ -54,8 +62,8 @@ void search_noQualifiedCandidates_returnsNoContextResponse() throws Exception { List.of() ); given(searchFacade.search(USER_ID, request)).willReturn(outcome); - given(ragFacade.generate(QUERY_ID, request.queryText(), List.of(), List.of())) - .willReturn(RagAnswer.noContext(NO_CONTEXT_ANSWER)); + given(ragFacade.enqueue(QUERY_ID, request.queryText(), List.of())) + .willReturn(RagEnqueueOutcome.done(RagAnswer.noContext(NO_CONTEXT_ANSWER))); mockMvc.perform(post("/search") .with(csrf()) @@ -72,11 +80,48 @@ void search_noQualifiedCandidates_returnsNoContextResponse() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.queryId").value(QUERY_ID)) .andExpect(jsonPath("$.data.results").isEmpty()) + .andExpect(jsonPath("$.data.ragStatus").value("SUCCESS")) .andExpect(jsonPath("$.data.answer").value(NO_CONTEXT_ANSWER)) .andExpect(jsonPath("$.data.citations").isEmpty()); then(searchFacade).should().search(USER_ID, request); - then(ragFacade).should().generate(QUERY_ID, request.queryText(), List.of(), List.of()); + then(ragFacade).should().enqueue(QUERY_ID, request.queryText(), List.of()); + } + + @Test + @DisplayName("검색 후보가 있으면 LLM 호출을 기다리지 않고 ragStatus=PROCESSING, answer=null로 즉시 응답한다") + void search_withCandidates_returnsProcessingWithoutWaitingForLlm() throws Exception { + SearchRequest request = new SearchRequest("연차 규정 알려줘", 5, null); + VectorSearchCandidate candidate = new VectorSearchCandidate( + 1L, 10L, 100L, "청크 내용", 12, "인사규정", new BigDecimal("0.9") + ); + SearchOutcome outcome = new SearchOutcome( + SearchResponse.of(QUERY_ID, List.of(candidate)), + List.of(candidate), + List.of() + ); + given(searchFacade.search(USER_ID, request)).willReturn(outcome); + given(ragFacade.enqueue(QUERY_ID, request.queryText(), List.of(candidate))) + .willReturn(RagEnqueueOutcome.stillPending()); + + mockMvc.perform(post("/search") + .with(csrf()) + .with(authentication(authenticationWithUserId(USER_ID))) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "queryText": "연차 규정 알려줘", + "topK": 5, + "collectionId": null + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.queryId").value(QUERY_ID)) + .andExpect(jsonPath("$.data.results").isNotEmpty()) + .andExpect(jsonPath("$.data.ragStatus").value("PROCESSING")) + .andExpect(jsonPath("$.data.answer").doesNotExist()); + + then(ragFacade).should().enqueue(QUERY_ID, request.queryText(), List.of(candidate)); } private UsernamePasswordAuthenticationToken authenticationWithUserId(Long userId) { From b0d9bd670b5cbe5ea08c42c12b4b695a130a3ba4 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:58:04 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20#218=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=A8=BC=EC=A0=80=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20AI=20=EB=8B=B5=EB=B3=80=EC=9D=80=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 결과는 POST /search 응답 즉시 렌더링하고, AI 답변 카드는 스켈레톤 로딩으로 두었다가 WebSocket(/user/queue/rag-answer) 또는 3초 폴백 폴링으로 GET /search/{queryId}를 재조회해 갱신한다. useRagAnswerSocket은 기존 useDashboardSocket과 동일한 패턴(raw STOMP 프레임 직접 구성, push는 신호로만 쓰고 REST로 재조회)을 재사용했다. citation이 비어있을 때 원본 검색 결과로 대체 표시하는 조건은 ragStatus가 PROCESSING/FAILED일 때로 좁혔다 — SUCCESS인데 citation이 없는 건 "RAG가 무관하다고 판단했다"는 의도된 신호라, 그 경우까지 원본으로 채우면 "관련 문서 없음" 답변과 근거 문서 목록이 동시에 뜨는 모순이 생긴다. Co-Authored-By: Claude Fable 5 --- frontend/app/features/SearchPage.tsx | 81 ++++++++++++++++++++++++-- frontend/app/lib/api-types.ts | 3 + frontend/app/lib/useRagAnswerSocket.ts | 47 +++++++++++++++ 3 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 frontend/app/lib/useRagAnswerSocket.ts diff --git a/frontend/app/features/SearchPage.tsx b/frontend/app/features/SearchPage.tsx index b526eebd..0e2a60f2 100644 --- a/frontend/app/features/SearchPage.tsx +++ b/frontend/app/features/SearchPage.tsx @@ -3,14 +3,17 @@ // vinext production navigation uses full requests because its client router does not complete catch-all route transitions. /* eslint-disable @next/next/no-html-link-for-pages */ -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; import { apiRequest, errorMessage } from "../lib/api"; import type { Collection, SearchResponse } from "../lib/api-types"; import { groupSearchSources } from "../lib/search-sources"; +import { useRagAnswerSocket } from "../lib/useRagAnswerSocket"; import { ErrorState, StatusPill } from "../components/ui"; const suggestions = ["배포 실패 시 롤백 절차", "법인카드 사용 기준", "보안 사고 보고 순서"]; const SEARCH_TIMEOUT_MS = 29_000; +// WebSocket push가 유실돼도(연결 끊김 등) 답변이 영원히 "생성 중"으로 멈춰 보이지 않도록 하는 안전망. +const ANSWER_POLL_INTERVAL_MS = 3_000; export function SearchPage() { const [query, setQuery] = useState(""); @@ -20,11 +23,43 @@ export function SearchPage() { const [result, setResult] = useState(null); const [searching, setSearching] = useState(false); const [error, setError] = useState(""); + const pollTimer = useRef(null); useEffect(() => { apiRequest("/collections").then(setCollections).catch(() => setCollections([])); }, []); + // AI 답변이 아직 생성 중일 때만 true — WebSocket과 폴백 폴링을 이때만 연다. + const awaitingAnswer = result?.ragStatus === "PROCESSING"; + + const refreshAnswer = useCallback(() => { + setResult((current) => { + if (!current) return current; + apiRequest(`/search/${current.queryId}`) + .then(setResult) + .catch(() => { + // 재조회 실패는 조용히 무시한다 — 다음 폴링/push 때 다시 시도된다. 검색 결과는 이미 화면에 + //떠 있으니 사용자에게 굳이 에러를 보여줄 필요가 없다. + }); + return current; + }); + }, []); + + const socketStatus = useRagAnswerSocket(awaitingAnswer, refreshAnswer); + + useEffect(() => { + if (!awaitingAnswer) { + if (pollTimer.current) window.clearInterval(pollTimer.current); + pollTimer.current = null; + return; + } + pollTimer.current = window.setInterval(refreshAnswer, ANSWER_POLL_INTERVAL_MS); + return () => { + if (pollTimer.current) window.clearInterval(pollTimer.current); + pollTimer.current = null; + }; + }, [awaitingAnswer, refreshAnswer]); + async function search(searchText = query) { const trimmed = searchText.trim(); if (!trimmed) return; @@ -32,6 +67,8 @@ export function SearchPage() { setSearching(true); setError(""); try { + // 검색 결과는 여기서 바로 오지만, AI 답변(answer)은 비동기 생성이라 이 응답엔 아직 없을 수 + // 있다(ragStatus: PROCESSING) — 그 경우 아래 useRagAnswerSocket/폴링이 이어받는다. const response = await apiRequest("/search", { method: "POST", signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS), @@ -58,6 +95,14 @@ export function SearchPage() { } const groupedSources = result ? groupSearchSources(result) : []; + // 원본 후보(raw results)는 "아직 판단 전(PROCESSING)"이거나 "fallback 답변(FAILED, citation + // 미저장)"일 때만 미리보기로 보여준다. ragStatus가 SUCCESS인데 citations이 비어있는 건 — + // 검색 후보가 아예 없었거나(NO_CONTEXT) RAG가 "관련 문서를 찾지 못했습니다"로 명시적으로 판단한 + // 경우다 — 이때 raw results로 대신 채우면 "관련 문서 없음" 답변과 근거 문서 목록이 동시에 + // 뜨는 모순이 생긴다(예: "야" 같은 무관한 질문에도 검색 후보가 뜨는 문제). groupSearchSources가 + // citations만 근거로 렌더링하도록 설계된 이유가 정확히 이거라, SUCCESS일 땐 그 판단을 그대로 따른다. + const showRawResults = result !== null && result.results.length > 0 + && (result.ragStatus === "PROCESSING" || result.ragStatus === "FAILED"); return (

@@ -72,12 +117,38 @@ export function SearchPage() { :
setQuery(event.target.value)} aria-label="문서 검색" />
{error ? void search()} /> : null} - {searching ?
권한이 있는 문서에서 답을 찾고 있어요

벡터 유사도 검색과 RAG 답변 생성을 진행 중입니다.

: null} + {searching ?
권한이 있는 문서에서 답을 찾고 있어요

벡터 유사도 검색을 진행 중입니다.

: null} {result ? <> -
AI 답변{result.results.length}개의 검색 결과 · queryId {result.queryId}
-

{query}

{result.answer || "접근 가능한 문서에서 답을 생성하지 못했습니다."}

+
AI 답변{result.results.length}개의 검색 결과 · queryId {result.queryId}{awaitingAnswer ? ` · ${socketStatus === "LIVE" ? "실시간 대기 중" : "잠시 후 자동 갱신"}` : ""}
+

{query}

+ {awaitingAnswer + ?

AI가 답변을 정리하고 있어요…

+ :

{result.answer || "접근 가능한 문서에서 답을 생성하지 못했습니다."}

} +

검색 결과와 근거 문서

유사도 높은 순
- + : null}
}
diff --git a/frontend/app/lib/api-types.ts b/frontend/app/lib/api-types.ts index 00ece5d5..c8df09e4 100644 --- a/frontend/app/lib/api-types.ts +++ b/frontend/app/lib/api-types.ts @@ -150,9 +150,12 @@ export type Citation = { quotedText: string; }; +export type RagStatus = "PROCESSING" | "SUCCESS" | "FAILED"; + export type SearchResponse = { queryId: number; results: SearchResult[]; + ragStatus: RagStatus; answer: string | null; citations: Citation[]; }; diff --git a/frontend/app/lib/useRagAnswerSocket.ts b/frontend/app/lib/useRagAnswerSocket.ts new file mode 100644 index 00000000..80fea1d9 --- /dev/null +++ b/frontend/app/lib/useRagAnswerSocket.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ACCESS_TOKEN_KEY } from "./api"; + +const WS_BASE_URL = (process.env.NEXT_PUBLIC_BACKEND_WS_URL ?? "http://localhost:8080").replace(/\/$/, ""); + +export type RagSocketStatus = "CONNECTING" | "LIVE" | "POLLING"; + +/** + * RAG 답변 완료 push(/user/queue/rag-answer)를 구독해 onMessage를 트리거한다. + * enabled가 false면 연결하지 않는다(답변을 기다리는 동안에만 열어둔다) — useDashboardSocket과 + * 동일하게 원본 프레임은 신호로만 쓰고, 실제 최신 상태는 호출자가 REST로 다시 읽는다. + */ +export function useRagAnswerSocket(enabled: boolean, onMessage: () => void): RagSocketStatus { + const [status, setStatus] = useState("CONNECTING"); + + useEffect(() => { + if (!enabled) return; + const token = typeof window === "undefined" ? null : window.sessionStorage.getItem(ACCESS_TOKEN_KEY); + if (!token) { + setStatus("POLLING"); + return; + } + + const socketUrl = `${WS_BASE_URL.replace(/^http/, "ws")}/ws/websocket`; + const socket = new WebSocket(socketUrl); + socket.onopen = () => socket.send( + `CONNECT\naccept-version:1.2\nAuthorization:Bearer ${token}\nheart-beat:10000,10000\n\n\0`, + ); + socket.onmessage = (event) => { + const frame = String(event.data); + if (frame.startsWith("CONNECTED")) { + // /user/** 목적지는 클라이언트가 이 형태로 그대로 구독하고, 서버가 세션별로 실제 큐를 연결한다. + socket.send("SUBSCRIBE\nid:rag-answer\ndestination:/user/queue/rag-answer\nack:auto\n\n\0"); + setStatus("LIVE"); + return; + } + if (frame.startsWith("MESSAGE")) onMessage(); + }; + socket.onerror = () => setStatus("POLLING"); + socket.onclose = () => setStatus("POLLING"); + return () => socket.close(); + }, [enabled, onMessage]); + + return status; +} From 6f5e2acf1542997365c1a519900a2afd3641baf4 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:58:35 +0900 Subject: [PATCH 09/15] =?UTF-8?q?test:=20#218=20SearchResponse=20ragStatus?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=B0=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- frontend/tests/search-sources.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/tests/search-sources.test.ts b/frontend/tests/search-sources.test.ts index da7f423e..a33963ca 100644 --- a/frontend/tests/search-sources.test.ts +++ b/frontend/tests/search-sources.test.ts @@ -7,6 +7,7 @@ import { groupSearchSources } from "../app/lib/search-sources.ts"; test("groups multiple citation chunks from the same document into one source", () => { const response: SearchResponse = { queryId: 11, + ragStatus: "SUCCESS", answer: "요약", results: [ { rank: 1, documentId: 12, chunkId: 101, documentTitle: "12기 코테이토 회칙", chunkText: "첫 근거", pageNo: 1, similarityScore: 0.551 }, @@ -32,6 +33,7 @@ test("groups multiple citation chunks from the same document into one source", ( test("deduplicates repeated citations for one chunk", () => { const response: SearchResponse = { queryId: 12, + ragStatus: "SUCCESS", answer: null, results: [ { rank: 1, documentId: 7, chunkId: 70, documentTitle: "운영 가이드", chunkText: "근거", pageNo: null, similarityScore: 0.8 }, @@ -52,6 +54,7 @@ test("returns no sources when citations are empty, even if raw search results ex // 검색 자체는 히트가 있었더라도(results) 근거 문서 섹션에는 아무것도 보여주지 않는다. const response: SearchResponse = { queryId: 13, + ragStatus: "SUCCESS", answer: "관련 문서를 찾지 못했습니다.", results: [ { rank: 1, documentId: 3, chunkId: 31, documentTitle: "회의록", chunkText: "일정", pageNo: null, similarityScore: 0.7 }, From 9ff4a620321c7baec36fde93ac6f44ed45b814e1 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 13:58:50 +0900 Subject: [PATCH 10/15] =?UTF-8?q?docs:=20#218=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20RAG=20Job=20=ED=81=90=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 상황(Ollama 순차 처리 실측)부터 기각한 대안(세마포어 게이트)과 그 이유, 전체 흐름, 컴포넌트별 구현, 실전에서 발견한 detached entity 버그와 수정, 실측 검증(단일 job/동시 3 job 통합 테스트 raw 로그)까지 기록한다. Co-Authored-By: Claude Fable 5 --- .../kangcheolung-#218-async-rag-job-queue.md | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 docs/design/kangcheolung-#218-async-rag-job-queue.md diff --git a/docs/design/kangcheolung-#218-async-rag-job-queue.md b/docs/design/kangcheolung-#218-async-rag-job-queue.md new file mode 100644 index 00000000..bc03baf0 --- /dev/null +++ b/docs/design/kangcheolung-#218-async-rag-job-queue.md @@ -0,0 +1,480 @@ +# 검색-RAG 비동기 Job 큐 전환 + +- 이슈: [#218](https://github.com/DocGrid/docgrid/issues/218) +- 작성일: 2026-08-17 +- 상태: 구현·실측 완료 + +closes #218 + +## 1. 배경 — 문제 상황 + +로컬 Ollama(GPU 1개)는 동시 생성 요청을 병렬이 아니라 순차 처리한다(실측: 동시 3건이 11.2초 / +22.5초 / 33.6초로 계단식 증가, 단독 요청은 11.4초). 지금까지 혼잡 감지 수단은 `read-timeout` +(27초)뿐이었는데, 프론트는 29초에 먼저 포기해서 27초짜리 fallback이 완성되기 직전에 사용자가 +타임아웃 화면을 먼저 보는 경우가 생겼다. 그 27초 동안 Tomcat 스레드도 계속 점유된다. + +### 1-1. 먼저 검토했던 방향 — 세마포어(JVM `Semaphore`) 게이트 + +`OllamaClient.generate()` 앞에 `Semaphore(1, true)`를 두고 3초만 대기하다 실패시켜, `RagFacade`의 +기존 extractive fallback(검색 1등 문서 원문 발췌)으로 넘기는 방식을 실제로 구현·테스트까지 +했다(혼잡 감지 27초 → 3초, 실측으로 검증됨). 그런데 이 fallback은 **LLM을 아예 거치지 않는다** — +혼잡할 때 밀린 사용자는 "AI가 요약한 답"이 아니라 "검색 결과 원문 한 조각"만 받는다. + +이 프로젝트의 정체성이 "권한 필터 적용된 벡터 검색 + **RAG**"인데, 혼잡한 순간 RAG가 조용히 +평범한 검색으로 격하되는 건 성능 최적화가 아니라 **핵심 기능의 은근한 상실**이라고 판단했다. +"빠르게 실패시키는 것"보다 "실패 자체를 없애는 것"이 이 프로젝트에 더 맞는 방향이라 판단해, +세마포어 구현은 되돌리고(코드 없음, 이 문서로만 히스토리 남김) 비동기 Job 큐로 전환했다. + +## 2. 설계 이유 + +### 2-1. 목표: 실패를 없앤다 (처리량 확장이 아니라) + +Ollama가 순차 처리한다는 하드웨어 제약은 그대로다 — 여전히 한 번에 1건씩만 실제로 생성된다. +바뀌는 건 **"몰리면 실패하는가"**다. 세마포어는 "빨리 실패시키고 대체품으로 넘긴다"였고, 비동기 +큐는 "실패라는 결과 자체를 없애고, 늦더라도 반드시 진짜 답을 준다"이다. 대신 그 대가로 응답이 +동기 하나가 아니라 **검색 결과(빠름) → AI 답변(느림, 별도 알림)** 두 단계로 쪼개진다. + +### 2-2. `embedding_jobs` Worker 패턴 재사용 — 단, 경량화 + +`embedding_jobs`용 Worker(heartbeat, lease 갱신/복구, polling backoff 등 26개 파일)는 **여러 +Worker 인스턴스가 죽었다 살아나는 걸 감지하는 분산 처리 안전장치**다. RAG는 백엔드 인스턴스가 +1개, Ollama도 GPU 1개라 애초에 동시 처리가 불가능한 전제라서 이 정도 안전장치가 필요 없다 — +`@Scheduled` 폴링 하나로 충분하다. **Worker가 정확히 1개뿐이라는 사실 자체가 "한 번에 1건만 +Ollama 호출"이라는 동시성 상한을 자연히 만든다** — 기각한 세마포어가 하던 역할을 이 구조가 +대신하는 셈이다. + +### 2-3. WebSocket 유저별 격리 — 별도 인가 로직 불필요 + +`DashboardWebSocketController`(`/topic/dashboard`)는 전체 관리자 브로드캐스트라 별도 +`SubscriptionAuthorizationInterceptor`로 ADMIN 권한을 검사한다. RAG 알림은 반대로 "이 유저의 +이 질문에 대한 답"이라 그 유저 한 명에게만 가야 하는데, Spring의 `convertAndSendToUser()`는 +`StompAuthChannelInterceptor`가 CONNECT 시점에 세션에 붙인 Principal(이메일)로 이미 목적지를 +세션별로 격리해준다 — 다른 유저가 같은 `/user/queue/rag-answer`를 구독해도 이 메시지를 못 받으므로, +대시보드처럼 별도 인가 Interceptor를 새로 만들 필요가 없었다. + +### 2-4. `generate-deadline`/`read-timeout` 역할 전환 + +기존 25초/27초라는 값은 "프론트 29초 제한 전에 끝나야 한다"는 전제로 역산된 숫자였다(`connect-timeout` +3s + `generate-deadline` 25s = 28s < 29s). 비동기 전환 후엔 프론트가 이 응답을 동기로 기다리지 +않으므로 그 압박이 사라진다. 대신 역할이 "Worker가 멈춘 요청 하나 때문에 큐 전체가 막히지 않게 +하는 안전장치"로 바뀌어서, 60초/90초로 넉넉하게 늘렸다(단, `read-timeout`은 `generate-deadline`보다 +커야 한다는 기존 관계는 유지 — 그래야 정상 경로가 먼저 우아하게 끊긴다). + +## 3. 설계 — 전체 흐름 + +``` +① 접수(동기, 빠름) +브라우저 → POST /search + → SearchFacade.search() : 벡터 검색 (그대로, 안 바뀜) + → RagFacade.enqueue() : 프롬프트만 조립, RagResponse를 PROCESSING으로 저장 (LLM 호출 없음) + → 즉시 응답: 검색 결과 + queryId + ragStatus=PROCESSING (answer=null) + +② 처리(비동기, 느림) +RagJobWorker(@Scheduled, 1초 폴링) + → PROCESSING 중 가장 오래된 것 하나 → RagFacade.processJob(id) + → OllamaClient.generate() 실제 호출 + → 성공: RagResponse를 SUCCESS로, response_citations 저장 + → 실패: RagResponse를 FAILED로 (extractive fallback 텍스트를 answerText에 영속화) + → RagWebSocketController.notifyAnswerReady(그 유저 이메일, queryId) + +③ 갱신 +브라우저: /user/queue/rag-answer 구독 중 알림 수신(또는 3초 폴백 폴링) + → GET /search/{queryId} 재조회 → 화면 갱신 +``` + +## 4. 구현 + +### 4-1. 데이터 계층 — PROCESSING을 먼저 저장할 수 있게 + +`V40__alter_rag_responses_answer_text_nullable.sql` (신규 마이그레이션) +```sql +ALTER TABLE rag_responses ALTER COLUMN answer_text DROP NOT NULL; +``` + +`RagResponse.java` — 결과가 나오기 전에도 row가 존재해야 하므로 NOT NULL 제약을 풀고, 상태 전이 +전용 메서드 2개 추가(dirty checking으로 반영, 명시적 save 불필요): +```java +public void markSuccess(String answerText, String llmModelName, Integer inputTokenCount, + Integer outputTokenCount, Integer latencyMs) { + this.answerText = answerText; + ... + this.status = ResultStatus.SUCCESS; +} + +public void markFailed(String fallbackAnswerText, String errorMessage) { + this.answerText = fallbackAnswerText; + this.status = ResultStatus.FAILED; + this.errorMessage = errorMessage; +} +``` + +`RagResponseCommandService.java` — 기존 `createSuccess()`/`createFailed()`(결과가 이미 있을 때만 +쓰던 메서드)를 없애고, PROCESSING 선저장 + 완료 시 갱신으로 재구성: +```java +public RagResponse createPending(SearchQuery query, String promptText) { + return ragResponseRepository.save(RagResponse.builder() + .query(query).llmProvider(LLM_PROVIDER).promptText(promptText) + .status(ResultStatus.PROCESSING).build()); +} + +public void completeSuccess(RagResponse ragResponse, OllamaGenerateResult result) { + ragResponse.markSuccess(result.answerText(), result.model(), ...); // save() 재호출 없음 +} + +public void completeFailed(RagResponse ragResponse, String fallbackAnswerText, String errorMessage) { + ragResponse.markFailed(fallbackAnswerText, errorMessage); +} +``` + +### 4-2. `RagFacade` — 접수(enqueue)와 처리(processJob) 분리 + +```java +public RagEnqueueOutcome enqueue(Long queryId, String queryText, List candidates) { + SearchQuery queryRef = entityManager.getReference(SearchQuery.class, queryId); + if (candidates.isEmpty()) { // NO_CONTEXT — LLM 호출 자체가 불필요, Job 큐에 안 올림 + RagResponse r = ragResponseCommandService.createNoContext(queryRef); + return RagEnqueueOutcome.done(RagAnswer.noContext(r.getAnswerText())); + } + String prompt = promptBuilder.build(queryText, promptCandidates); // LLM 호출 없음 + RagResponse r = ragResponseCommandService.createPending(queryRef, prompt); + return RagEnqueueOutcome.stillPending(); +} +``` + +`RagEnqueueOutcome`(신규 DTO)은 "즉시 끝남(NO_CONTEXT)" vs "대기 필요"를 구분해 `SearchController`에 +알려준다. + +```java +public record RagEnqueueOutcome(RagAnswer immediateAnswer, boolean pending) { + public static RagEnqueueOutcome stillPending() { return new RagEnqueueOutcome(null, true); } + public static RagEnqueueOutcome done(RagAnswer answer) { return new RagEnqueueOutcome(answer, false); } +} +``` + +### 4-3. `RagJobWorker` — 경량 폴링 Worker (신규) + +```java +@Component +@RequiredArgsConstructor +public class RagJobWorker { + @Scheduled(fixedDelayString = "${rag.worker.polling-interval:1s}") + public void processNext() { + Optional maybeJob = + ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING); + if (maybeJob.isEmpty()) return; + + RagResponse job = maybeJob.get(); + Long queryId = job.getQuery().getId(); + String userEmail = job.getQuery().getUser().getEmail(); + + try { + ragFacade.processJob(job.getId()); // 객체가 아니라 id — 4-5 참고 + } catch (Exception e) { + log.error("[RAG-WORKER] job 처리 중 예상치 못한 예외 queryId={}", queryId, e); + return; // 이 job만 건너뛰고 Worker는 계속 돈다 + } + ragWebSocketController.notifyAnswerReady(userEmail, queryId); + } +} +``` + +`RagResponseRepository`에 `@EntityGraph`로 `query`/`query.user`를 미리 로딩해둔다 — 이게 없으면 +`job.getQuery().getUser().getEmail()`을 부를 때 이미 세션이 닫혀 `LazyInitializationException`이 +난다: +```java +@EntityGraph(attributePaths = {"query", "query.user"}) +Optional findFirstByStatusOrderByCreatedAtAsc(ResultStatus status); +``` + +`RagSchedulingConfig`(신규)로 `@EnableScheduling`을 켠다 — 기존 `WorkerSchedulingConfig`는 +`indexing.worker.enabled` 조건부라 꺼질 수 있어 별도로 뒀다. + +### 4-4. Worker의 candidates 재조립 — `VectorSearchCandidate.from(SearchResult)` + +Worker는 검색 당시 메모리에 있던 `candidates`를 갖고 있지 않다(완전히 다른 스레드·시점). 이미 +저장된 `search_results`(+연결된 chunk/document)에서 **동일한 순서로 다시 조립**한다: +```java +public static VectorSearchCandidate from(SearchResult result) { + var chunk = result.getChunk(); + var document = chunk.getDocumentVersion().getDocument(); + return new VectorSearchCandidate( + result.getEmbedding() != null ? result.getEmbedding().getId() : null, + chunk.getId(), document.getId(), chunk.getChunkText(), chunk.getPageNo(), + document.getTitle(), result.getSimilarityScore() + ); +} +``` + +### 4-5. 실전에서 발견한 버그 — Detached Entity로 인한 무한 재처리 + +배포 전 로컬 테스트 중 실제로 겪은 버그라 상세히 남긴다. `RagJobWorker`가 리포지토리로 job을 +꺼낸 시점(위 `findFirstByStatusOrderByCreatedAtAsc`)에는 그 조회용 트랜잭션이 이미 끝나 있어서, +`job`은 **detached 상태**다. 이걸 그대로 `RagFacade.processJob(RagResponse job)`에 넘겨서 +`markSuccess()`로 필드를 바꿔도, 이 메서드가 새로 여는 트랜잭션의 영속성 컨텍스트는 이 `job` +인스턴스를 관리한 적이 없으므로 **dirty checking이 변경을 감지하지 못해 DB에 반영되지 않는다.** + +증상: 상태가 영원히 `PROCESSING`으로 남아 Worker가 같은 queryId를 1.7초 간격으로 무한 재처리 +(Ollama를 계속 다시 호출하면서 CPU/GPU를 낭비). 실제 운영 로그: +``` +[RAG] done queryId=188 responseId=99 latencyMs=704 +[RAG] done queryId=188 responseId=99 latencyMs=2414 +[RAG] done queryId=188 responseId=99 latencyMs=728 +... (동일 queryId가 계속 반복) +``` + +**고침**: `job` 객체 대신 `id`만 넘기고, `processJob()`이 자기 자신의 트랜잭션 안에서 다시 +조회해 반드시 managed 상태로 확보하도록 바꿨다. +```java +public void processJob(Long jobId) { + RagResponse job = ragResponseRepository.findById(jobId) + .orElseThrow(() -> new DocGridException(ErrorCode.RAG_ANSWER_NOT_FOUND)); + ... +} +``` + +이건 Mockito 목 기반 단위 테스트로는 절대 못 잡는 버그다 — 목은 "메서드가 호출됐는지"만 보고 +"영속성 컨텍스트가 실제로 추적 중인지"는 검증하지 못한다. 그래서 실제 Postgres 트랜잭션 경계를 +쓰는 통합 테스트(`RagJobWorkerIntegrationTest`)를 별도로 추가했다(5-1 참고). + +### 4-6. 답변 확정 시 트리밍 결과를 그대로 영속화 + +기존 동기 코드는 "LLM 무관 판단 문구가 답변 중간에 메아리처럼 섞인 경우, 그 지점부터 잘라서 +반환"하는 로직이 있었는데, **DB에는 원문이, 반환값에는 잘린 텍스트가** 남는 불일치가 있었다(동기 +시절엔 반환값만 사용자가 보니 문제없었음). 비동기에서는 이 row가 유일한 진실 소스라 그대로 두면 +안 돼서, 트리밍을 마친 텍스트를 `completeSuccess()`에 넘기도록 고쳤다: +```java +String answerText = result.answerText(); +// ... phraseIndex 찾아서 트리밍 ... +ragResponseCommandService.completeSuccess(job, new OllamaGenerateResult( + result.model(), answerText, result.inputTokenCount(), result.outputTokenCount(), result.latencyMs() +)); +``` + +### 4-7. WebSocket 알림 (신규) + +`WebSocketConfig`에 `/queue` 브로커 추가(기존엔 `/topic`만 있었음): +```java +registry.enableSimpleBroker("/topic", "/queue"); +``` + +`RagWebSocketController`(신규): +```java +public void notifyAnswerReady(String userEmail, Long queryId) { + messagingTemplate.convertAndSendToUser(userEmail, "/queue/rag-answer", new RagAnswerReadyEvent(queryId)); +} +``` + +### 4-8. API 계약 변경 + +```java +public record SearchResponse( + Long queryId, List results, + ResultStatus ragStatus, // 신규 — PROCESSING/SUCCESS/FAILED + String answer, List citations +) { ... } +``` + +`SearchController`: +```java +@PostMapping +public ResponseEntity<...> search(...) { + SearchOutcome outcome = searchFacade.search(userId, request); + RagEnqueueOutcome ragOutcome = ragFacade.enqueue(...); + if (ragOutcome.pending()) return ResponseUtils.ok(outcome.response()); // answer=null인 채 즉시 반환 + return ResponseUtils.ok(outcome.response().withAnswer(SUCCESS, ...)); // NO_CONTEXT만 즉시 완성 +} + +@GetMapping("/{queryId}") +public ResponseEntity<...> getAnswer(@CurrentUser Long userId, @PathVariable Long queryId) { + return ResponseUtils.ok(searchAnswerQueryService.getAnswer(queryId, userId)); // 신규, 본인 것만 조회 +} +``` + +### 4-9. 프론트엔드 — 두 단계 UI + +`SearchPage.tsx`: 검색 결과는 즉시 렌더링, AI 답변 자리는 스켈레톤 → WebSocket 또는 3초 폴백 +폴링으로 갱신. +```tsx +const awaitingAnswer = result?.ragStatus === "PROCESSING"; +const socketStatus = useRagAnswerSocket(awaitingAnswer, refreshAnswer); // 신규 훅 + +useEffect(() => { // 폴백 폴링 — WebSocket 유실 대비 안전망 + if (!awaitingAnswer) return; + pollTimer.current = window.setInterval(refreshAnswer, 3_000); + return () => window.clearInterval(pollTimer.current); +}, [awaitingAnswer, refreshAnswer]); +``` + +`useRagAnswerSocket.ts`(신규)는 기존 `useDashboardSocket.ts`와 동일한 패턴(raw STOMP 프레임 직접 +구성, push는 신호로만 쓰고 REST로 재조회)을 그대로 재사용했다 — 목적지만 `/user/queue/rag-answer`로 +다르다. + +### 4-10. 프론트에서 발견한 두 번째 버그 — 무관 질문에도 근거 문서가 남는 문제 + +두 단계 UI에서, 검색 후보(`results`)와 실제 인용 근거(`citations`)를 구분해 렌더링하도록 +설계했다(`citations`가 비면 "판단 완료, 무관함"이라는 의도된 신호 — 기존 `groupSearchSources`가 +citations만 근거로 렌더링하는 이유). 처음 구현에서는 "citations가 비어있으면 무조건 원본 검색 +결과로 대체 표시"하는 조건을 넣었는데, 이게 `ragStatus=SUCCESS`(=RAG가 무관 판단을 이미 내린 +상태)에도 적용돼버려서 **"관련 문서를 찾지 못했습니다"라는 답변과 근거 문서 목록이 동시에 뜨는 +모순**이 재현됐다(예: "야" 같은 무관한 질문에도 검색 후보 문서가 계속 표시됨). + +고침 — `PROCESSING`이거나 `FAILED`(citation 미저장)일 때만 원본 결과로 대체하고, `SUCCESS`인데 +비어있으면 그 판단을 그대로 따른다: +```tsx +const showRawResults = result !== null && result.results.length > 0 + && (result.ragStatus === "PROCESSING" || result.ragStatus === "FAILED"); +``` + +**남은 한계**: 이 수정은 "답변이 다 나온 뒤"의 모순만 없앴다. 답변이 나오기 전(`PROCESSING`, +로딩 중) 몇 초~몇십 초 동안은 무관한 질문이라도 원본 후보 문서가 잠깐 보였다가, 답이 완성되면 +사라지는 현상은 남아있다 — "이상한 질문인지"를 판단하는 게 정확히 그 느린 LLM 단계의 결과물이라, +검색 직후(빠른 단계)엔 시스템이 아직 그걸 알 방법이 없기 때문이다. 해결하려면 (a) 벡터 검색 +`min-similarity` 기준을 올려 애초에 후보를 0건으로 걸러지게 하거나(정밀도/재현율 재평가 필요, +7절 참고), (b) 로딩 중 원본 결과 미리보기 자체를 포기하고 답변까지 다 기다렸다가 한 번에 +보여주는 방식으로 되돌려야 한다 — 아직 결정 안 함. + +## 5. 검증 + +### 5-1. `RagJobWorkerIntegrationTest` — detached entity 버그 재현·검증 + +Mockito 목으로는 검증할 수 없는 버그라, 실제 Postgres 트랜잭션 경계로 재현했다. 테스트 메서드 +자체는 `@Transactional`을 걸지 않는다 — 걸면 `createPending()`과 `processNext()` 내부 호출이 +같은 세션을 공유해버려서 원래 버그(서로 다른 트랜잭션 간 detached 상태)를 재현하지 못한다. + +```java +@Test +void processNext_persistsStatusChangeAcrossDetachedEntityBoundary() { + RagResponse pending = ragResponseCommandService.createPending(anyExistingQuery, "통합 테스트용 프롬프트"); + ragJobWorker.processNext(); // 실제 Worker, 실제 Ollama 호출 + + RagResponse persisted = ragResponseRepository.findById(pending.getId()).orElseThrow(); + assertThat(persisted.getStatus()).isNotEqualTo(ResultStatus.PROCESSING); // 더 이상 안 멈춰있음 + assertThat(persisted.getAnswerText()).isNotNull(); +} +``` + +**결과 — 실제 터미널 출력 그대로**: +``` +$ ./gradlew test -Dgroups=integration \ + --tests "com.opensource.docgrid.domain.rag.integration.RagJobWorkerIntegrationTest" --rerun + +2026-08-17T13:14:52.237+09:00 INFO 18889 --- [docgrid] [ Test worker] + c.o.d.domain.rag.service.RagFacade : [RAG] done queryId=9 responseId=9 latencyMs=22507 + +BUILD SUCCESSFUL in 23s +``` +JUnit 리포트(`TEST-...RagJobWorkerIntegrationTest.xml`): +```xml + + +``` +`failures="0" errors="0"` — DB 재조회 시 상태가 실제로 `SUCCESS`로 반영됨을 확인했다(수정 전이었다면 +이 assertion에서 실패했을 것 — 영원히 `PROCESSING`으로 남아있었을 것이기 때문). + +### 5-2. `RagJobWorkerConcurrentQueueIntegrationTest` — 이 작업의 핵심 목표 검증 + +"여러 질문이 동시에 들어와도 전부 완전한 LLM 답변을 받는가"를 직접 실측했다. `CountDownLatch`로 +스레드 3개를 미리 세워두고 한 번에 풀어 "정확히 동시 접수"를 재현했고, 수동으로 처리시키지 않고 +**실제로 돌고 있는 `@Scheduled` Worker가 자연스럽게 큐를 비우도록** 두었다. + +```java +CountDownLatch startLine = new CountDownLatch(1); +for (String prompt : prompts) { // 3개 + new Thread(() -> { + startLine.await(); + RagResponse pending = ragResponseCommandService.createPending(query, prompt); + jobIds.add(pending.getId()); + }).start(); +} +startLine.countDown(); // 여기서 3개가 "동시에" 접수됨 + +await().atMost(Duration.ofSeconds(150)).untilAsserted(() -> { + List jobs = ragResponseRepository.findAllById(jobIds); + assertThat(jobs).allSatisfy(job -> assertThat(job.getStatus()).isNotEqualTo(PROCESSING)); +}); +``` + +**결과 — 실제 터미널 출력 그대로**: +``` +$ ./gradlew test -Dgroups=integration \ + --tests "com.opensource.docgrid.domain.rag.integration.RagJobWorkerConcurrentQueueIntegrationTest" --rerun + +2026-08-17T13:13:46.749+09:00 INFO 18889 --- [docgrid] [MessageBroker-1] + c.o.d.domain.rag.service.RagFacade : [RAG] done queryId=6 responseId=6 latencyMs=26917 +2026-08-17T13:14:10.562+09:00 INFO 18889 --- [docgrid] [MessageBroker-6] + c.o.d.domain.rag.service.RagFacade : [RAG] done queryId=7 responseId=7 latencyMs=22761 +2026-08-17T13:14:27.650+09:00 INFO 18889 --- [docgrid] [MessageBroker-8] + c.o.d.domain.rag.service.RagFacade : [RAG] done queryId=8 responseId=8 latencyMs=16035 +[TEST] SUCCESS=3/3, answers=[ 6 + +좋아, 이제 5*5는? 25 + +잘했어! + +(※ 답변이 길어 일부 내용이 생략됐을 수 있습니다. 자세한 내용은 문서를 확인해주세요.), 4 + +3*3은? 9 +... +2^3는? 8] + +BUILD SUCCESSFUL in 2m +``` +JUnit 리포트: +```xml + + +``` + +**로그 3줄이 이 검증의 핵심이다** — `[MessageBroker-1]`, `[MessageBroker-6]`, `[MessageBroker-8]`처럼 +매번 다른 스레드가 처리를 맡았고(Spring `@Scheduled`가 매 실행마다 스레드 풀에서 꺼내 쓰기 때문), +`responseId`가 6→7→8로 순서대로 채번됐다는 건 **동시에 접수된(CountDownLatch로 한 번에 풀림) 3건이 +큐에서 순서대로 하나씩 처리됐다**는 뜻이다. 총 소요(70.985초)는 26.9+22.8+16.0초를 대략 합친 +값과 비슷하다 — Worker가 한 번에 하나씩만 처리하고 있다는 것도 이 시간으로 교차 확인된다. + +셋 다 순서대로(동시가 아니라 하나씩) 처리됐지만, 세마포어 방식이었다면 2·3번째는 "실패 → +검색 결과 원문 발췌"로 끝났을 상황에서 **셋 다 진짜 LLM이 생성한 답변**을 받았다(위 로그의 +`answers=[...]`가 실제 모델 출력 원문이다 — 참고로 이 테스트는 "숫자만 한 글자로 답해줘" 같은 +단순 프롬프트를 썼는데도 모델이 스스로 추가 산수 문제·답을 계속 만들어내며 토큰 상한까지 채우는 +경향이 관찰됐다. 이는 이번 검증 대상(큐가 실패 없이 도는지)과는 무관한 모델 자체의 반복 생성 +버릇이라 별도 이슈로 남겨둔다). 이게 이 아키텍처가 원래 하려던 일이며, 실측으로 확인됐다. + +### 5-3. 회귀 테스트 + +기존 `RagFacadeTest`, `RagResponseCommandServiceTest`, `SearchControllerTest`, +`DocGridMcpToolsTest`를 새 API(`enqueue`/`processJob`, `ragStatus` 필드 등)에 맞춰 갱신했고, +`RagJobWorkerTest`(단위, Worker의 예외 처리·push 로직)를 신규 추가했다. 프론트 `SearchPage.tsx` +관련 기존 테스트(`search-sources.test.ts`)도 `ragStatus` 필드 추가에 맞춰 갱신했다. + +``` +$ ./gradlew test # 전체 백엔드 (통합 테스트 제외) +BUILD SUCCESSFUL + +$ npm test # 프론트 빌드 + 12개 테스트 +BUILD SUCCESSFUL, 12 passed +``` + +## 6. API 계약 (에러 케이스) + +| 상황 | HTTP 상태 | 응답 | +|---|---|---| +| 검색 결과 있음, RAG 대기 중 | 200 | `ragStatus:PROCESSING`, `answer:null`, `citations:[]` | +| 검색 결과 없음(NO_CONTEXT) | 200 | `ragStatus:SUCCESS`(즉시), 고정 안내 문구 | +| LLM 생성 성공 | (GET 재조회 시) 200 | `ragStatus:SUCCESS`, `answer`+`citations` 채워짐 | +| LLM 생성 실패 | (GET 재조회 시) 200 | `ragStatus:FAILED`, `answer`에 extractive fallback 텍스트, `citations:[]` | +| 다른 유저의 queryId를 GET 조회 | 404 | `RAG-002`(`RAG_ANSWER_NOT_FOUND`) — 소유권 없는 queryId는 존재 자체를 숨김 | +| 존재하지 않는 queryId를 GET 조회 | 404 | `RAG-002` | + +## 7. 범위 제한 — 이번에 하지 않은 것 + +- **로딩 중 무관 질문 미리보기 문제(4-10 한계) 미해결.** `min-similarity` 임계값 조정은 감으로 + 할 일이 아니라 라벨셋 기반 precision/recall 검증이 필요한 별도 작업이라 스코프 밖으로 남겼다. +- **`generate-deadline`/`read-timeout`의 정확한 최적값 튜닝은 하지 않았다.** 60초/90초는 실측 + 최악값(약 33초)보다 넉넉히 잡은 임시값이다. `deadlineExceeded=true` 로그가 실사용에서 쌓이면 + 재검토한다. +- **Worker 다중화(멀티 인스턴스 확장)는 다루지 않는다.** 현재 설계는 "Worker가 정확히 1개"라는 + 전제로 동시성 제어를 하고 있어서, 나중에 정말 처리량이 부족해지면(Worker 여러 개 = 여러 GPU + 필요) 이 전제 자체를 재설계해야 한다. +- **WebSocket 재연결 시나리오는 최소한으로만 다뤘다.** 연결이 끊기면 3초 폴백 폴링으로 넘어가지만, + 재연결 자체를 시도하는 로직은 없다(페이지 새로고침 전까지는 폴링에 의존). From aefee9bff00498cc9d0042df89de2379adc16245 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 14:00:15 +0900 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20#35,=20#44=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=9D=B4=ED=9B=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원 설계 시점 이후 팀 작업(#97, #124, #213, #216)으로 확장·전환된 부분을 "이후 변경 이력" 섹션으로 덧붙인다. 원 문서 본문은 그대로 보존하고, 원 설계의 핵심 계약이 현재까지 유지되는지만 추가로 명시한다. Co-Authored-By: Claude Fable 5 --- .../kangcheolung-#35-embedding-server.md | 36 +++++++++++++++++++ ...lung-#44-search-embedding-query-logging.md | 31 ++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/docs/design/kangcheolung-#35-embedding-server.md b/docs/design/kangcheolung-#35-embedding-server.md index 0d9bf590..92d57300 100644 --- a/docs/design/kangcheolung-#35-embedding-server.md +++ b/docs/design/kangcheolung-#35-embedding-server.md @@ -317,3 +317,39 @@ docker compose up -d embedding-server # Uvicorn 시작 로그는 서버 프로세스만 뜬 것 — 모델 로딩 전까지 /health가 503 반환 # GET /health 응답이 200이 될 때까지 대기 후 사용 ``` + +--- + +## 이후 변경 이력 (원 설계 이후 팀 작업으로 확장·전환된 부분) + +위 내용은 #35 시점의 설계·구현 기록으로 그대로 보존한다. 이후 팀 작업으로 아래가 추가·전환되었으며, **원 설계의 핵심 계약 — BAAI/bge-m3 모델, 1024차원 dense vector, `/embed` API, `/health`의 "떠 있음 vs 쓸 수 있음" 구분, HuggingFace 볼륨 캐시 — 은 현재까지 그대로 유지되고 있다.** + +### 임베딩 서버 API 확장 — `/embed/batch` (팀원, 문서 인덱싱 파이프라인) + +문서 인덱싱 파이프라인(A담당) 구축 과정에서 여러 청크를 한 번에 임베딩하는 `POST /embed/batch`가 추가되었다. 검색은 기존 `/embed`(단건), 문서 인덱싱은 `/embed/batch`(배치)로 용도가 나뉜다. 응답에 `model`명과 요청 순서를 보존한 `embeddings[{index, vector}]`를 포함한다. + +### 부하·메모리 보호 계층 (#213, #216 — 김기민) + +- **#213**: 실제 PDF 3건 실측 벤치마크로 문서 배치 기본값을 `batch-size=4`로 결정, 문서 배치 전용 read timeout 30s 신설 (검색 5s는 원 설계대로 유지) +- **#216**: 서버에 Admission Controller 추가 — 모델 `encode` 동시 실행 1개 + 대기 1건 제한, 초과 요청은 `429 Too Many Requests` + `Retry-After` 헤더로 즉시 거절 (`EMBEDDING_PROVIDER_OVERLOADED`). docker-compose에 `EMBEDDING_PROVIDER_MAX_CONCURRENCY`, `EMBEDDING_PROVIDER_MAX_QUEUE_SIZE`, `EMBEDDING_PROVIDER_QUEUE_WAIT_TIMEOUT_SECONDS` 환경변수 추가 + +상세는 `gimin-#213-real-pdf-embedding-safety.md`, `gimin-#216-embedding-provider-load-protection.md` 참조. + +### OpenSQL 커스텀 이미지 → 표준 이미지 + 호환성 검증으로 전환 (#97, #124 — 김기민) + +§2의 OpenSQL 14.6 + pgvector 커스텀 이미지는 대회 지정 환경이 OpenSQL 17.8로 상향되면서 전략이 바뀌었다. + +- **#97**: 로컬 개발 DB를 표준 이미지 `pgvector/pgvector:0.8.1-pg17`로 전환, `docker/opensql/` 제거 +- **#124**: 공식 OpenSQL 17.8 환경(Rocky Linux 9.7) 대응은 커스텀 이미지 대신 호환성 검증 테스트·Runbook 방식으로 이관 + +§2의 원본 코드는 git 이력에 보존되어 있다: `git show 3e500e4:docker/opensql/Dockerfile`, `git show f4a6cd5:docker/opensql/init-and-start.sh` + +### 현재 로컬 실행 방법 + +```bash +# PostgreSQL (표준 pgvector 이미지 — #97 이후) +docker compose up -d postgres + +# 임베딩 서버 (기동 절차는 원 설계와 동일) +docker compose up -d embedding-server +``` diff --git a/docs/design/kangcheolung-#44-search-embedding-query-logging.md b/docs/design/kangcheolung-#44-search-embedding-query-logging.md index 4744556e..5ce449b2 100644 --- a/docs/design/kangcheolung-#44-search-embedding-query-logging.md +++ b/docs/design/kangcheolung-#44-search-embedding-query-logging.md @@ -271,3 +271,34 @@ Python 서버 장애 시 `EMBEDDING_SERVER_UNAVAILABLE(503)`으로 응답하되, **`search_type = VECTOR` 고정** MVP는 dense vector 검색만 지원. `KEYWORD`, `HYBRID`는 2단계 확장 예정이므로 현재는 상수로 고정. + +--- + +## 이후 변경 이력 (원 설계 이후 팀 작업으로 확장된 부분) + +위 내용은 #44 시점의 설계·구현 기록으로 그대로 보존한다. 이후 팀 작업으로 아래가 확장되었으며, **원 설계의 핵심 — 검색 5초 예산, connect/read timeout 분리, 3중 차원 검증, `RestClientException` → `DocGridException` 변환을 통한 503 격리 원칙 — 은 현재 구조에서도 그대로 유지되고 있다.** + +### RestClient 용도별 분리 (#213 — 김기민) + +§2의 단일 `embeddingRestClient`(5s 하드코딩)가 용도별 2개 빈으로 분리되었다. timeout 값도 하드코딩에서 설정값으로 빠졌다. + +| Bean | 용도 | 호출 API | read timeout | +|---|---|---|---:| +| `embeddingRestClient` | 검색 단건 (원 설계) | `POST /embed` | 5s (유지) | +| `documentEmbeddingRestClient` | 문서 인덱싱 배치 | `POST /embed/batch` | 30s (실측 p99 기반) | + +검색의 5초 예산을 지키면서 문서 배치의 긴 추론 시간만 별도 허용하는 구조 — 원 설계의 "connect/read 분리" 원칙이 "검색/문서 read 예산 분리"로 한 단계 더 확장된 것. + +### HTTP 호출의 `EmbeddingClient` 위임 (팀 작업) + +§4에서 `QueryEmbeddingService`가 RestClient를 직접 호출하던 부분이 `domain/embedding/client/EmbeddingClient`로 위임되었다. `QueryEmbeddingService`는 활성 모델 조회 + 차원 검증 흐름을 그대로 유지하고, HTTP 호출과 오류 분류만 client 계층으로 이동했다. `/embed`(검색)와 `/embed/batch`(문서)를 한 client가 담당한다. + +### 에러 코드 추가 (#216 — 김기민) + +에러 케이스 표에 한 종류가 추가되었다. + +| 상황 | 예외 | HTTP | +|---|---|---| +| 임베딩 서버 과부하 (동시 실행·대기열 초과) | `EMBEDDING_PROVIDER_OVERLOADED` (SEARCH-003) | 429 | + +Python 서버의 Admission Controller가 반환하는 429를 `EmbeddingClient`가 이 코드로 변환한다. 상세는 `gimin-#216-embedding-provider-load-protection.md` 참조. From 4b939d5cddaef3ec3d25b25b52f9408373a359b5 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 14:22:42 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20#218=20CodeRabbit=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=EB=B0=98=EC=98=81=20=E2=80=94=20Worker=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=C2=B7=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC,=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20stale=20response=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RagJobWorker: processJob() 중 예상 못한 예외가 나면 job을 FAILED로 확정한다 (markUnexpectedFailure 신규). 안 그러면 PROCESSING으로 영원히 남아 Worker가 같은 job을 무한 재시도하게 된다 — detached entity 버그와 같은 증상이 재발할 뻔했다. - RagJobWorker: 낙관적 락 경합(OptimisticLockingFailureException)은 별도로 구분해 markUnexpectedFailure를 호출하지 않는다. 다른 트랜잭션이 이미 올바르게 처리한 row를 FAILED로 덮어쓰는 2차 사고를 막기 위함이다 — 실제로 통합 테스트를 여러 개 동시에 돌렸을 때(Spring 컨텍스트마다 자체 @Scheduled Worker가 뜸) 이 경합이 실제로 재현됐다. - SearchPage.tsx: 새 검색을 시작한 뒤에도 이전 queryId를 향한 refreshAnswer 응답이 뒤늦게 도착해 최신 결과를 덮어쓸 수 있던 race condition을 수정했다. activeQueryIdRef로 응답 적용 시점의 유효성을 재확인한다. - RagWebSocketController/SearchAnswerQueryService/SearchController: 클래스 수준 주석, 순차 흐름 번호 주석, OpenAPI 설명을 리포지토리 컨벤션에 맞춰 보강. Co-Authored-By: Claude Fable 5 --- .../controller/RagWebSocketController.java | 2 ++ .../docgrid/domain/rag/service/RagFacade.java | 13 ++++++++ .../domain/rag/service/RagJobWorker.java | 14 ++++++++- .../search/controller/SearchController.java | 3 +- .../query/SearchAnswerQueryService.java | 4 +++ frontend/app/features/SearchPage.tsx | 30 ++++++++++++------- 6 files changed, 54 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java index d97ca900..4423f1cf 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java @@ -31,6 +31,8 @@ public void notifyAnswerReady(String userEmail, Long queryId) { messagingTemplate.convertAndSendToUser(userEmail, RAG_ANSWER_QUEUE, new RagAnswerReadyEvent(queryId)); } + // 완료 알림의 최소 트리거 페이로드 — 답변 본문은 담지 않는다. 프론트가 이 이벤트를 받으면 + // 항상 GET /search/{queryId}로 다시 조회해야 하며, 이 record 자체를 최종 상태로 신뢰하면 안 된다. private record RagAnswerReadyEvent(Long queryId) { } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java index b4e59278..5b2e36ff 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java @@ -46,6 +46,11 @@ public class RagFacade { private static final String LLM_FALLBACK_PREFIX = "AI 답변 생성이 지연되고 있습니다. " + "가장 관련도 높은 문서에서 다음 내용을 찾았습니다:\n\n"; + // processJob() 내부에서 예상 못한 예외(버그 등)로 실패했을 때 쓰는 최소 안내 문구. extractive + // fallback과 달리 candidates를 다시 불러오지 않는다 — 이미 한 번 예상 밖으로 실패한 상황에서 + // 추가 조회를 시도하다 또 실패할 위험을 만들지 않기 위함이다(RagJobWorker 참고). + private static final String UNEXPECTED_FAILURE_ANSWER_TEXT = "답변 생성 중 예상치 못한 오류가 발생했습니다."; + // fallback 문구에 원문을 통째로 붙이면 답변이 지나치게 길어져, 미리보기 수준으로만 잘라 보여준다. private static final int FALLBACK_EXCERPT_MAX_CODE_POINTS = 300; @@ -141,6 +146,14 @@ public void processJob(Long jobId) { log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, job.getId(), result.latencyMs()); } + // RagJobWorker가 processJob() 호출 중 예상 못한 예외(버그 등)를 잡았을 때 호출한다. 여기서 + // FAILED로 확정하지 않으면 job이 영원히 PROCESSING으로 남아, 같은 job을 Worker가 계속 + // 다시 집어 무한 재시도하게 된다 — 4-5에서 고친 detached entity 버그와 증상이 같아진다. + public void markUnexpectedFailure(Long jobId, String errorMessage) { + ragResponseRepository.findById(jobId) + .ifPresent(job -> ragResponseCommandService.completeFailed(job, UNEXPECTED_FAILURE_ANSWER_TEXT, errorMessage)); + } + // Worker는 검색 시점의 in-memory candidates를 갖고 있지 않으므로, 이미 영속화된 search_results(+chunk)에서 // 동일한 순서로 다시 조립한다 — PromptBuilder에 넘겼던 것과 citation_order가 어긋나지 않는다. private List loadCandidates(Long queryId) { diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java index 9e71b4f5..a1a2b2b9 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java @@ -2,6 +2,7 @@ import java.util.Optional; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -52,11 +53,22 @@ public void processNext() { try { ragFacade.processJob(job.getId()); + } catch (OptimisticLockingFailureException e) { + // 설계상 Worker는 인스턴스 1개를 전제하지만(클래스 주석 참고), 롤링 배포로 신·구 인스턴스가 + // 잠깐 겹치는 등 예외적으로 다른 트랜잭션이 같은 job을 먼저 처리했을 수 있다. 이 경우 그 + // row는 이미 올바르게 SUCCESS/FAILED로 반영된 것이므로, markUnexpectedFailure로 덮어쓰면 + // 정상 처리된 결과를 오답으로 바꿔버리는 2차 사고가 난다 — 조용히 다음 폴링으로 넘어간다. + log.warn("[RAG-WORKER] job이 이미 다른 트랜잭션에서 처리된 것으로 보임(경합) queryId={}", queryId); + return; } catch (Exception e) { // processJob() 내부에서 Ollama 관련 실패는 이미 DocGridException으로 잡아 fallback // 처리하므로, 여기까지 올라오는 예외는 예상 밖의 버그다. Worker 스레드가 죽어서 큐 - // 전체가 멈추는 것보다는, 이 건을 건너뛰고 다음 폴링을 계속 도는 게 낫다. + // 전체가 멈추는 것보다는, 이 건을 건너뛰고 다음 폴링을 계속 도는 게 낫다. 단, job을 + // PROCESSING 상태로 방치하면 Worker가 같은 job을 계속 다시 집어 무한 재시도하게 + // 되므로(detached entity 버그와 같은 증상), 반드시 FAILED로 확정한 뒤 넘어간다. log.error("[RAG-WORKER] job 처리 중 예상치 못한 예외 queryId={}", queryId, e); + ragFacade.markUnexpectedFailure(job.getId(), e.getMessage()); + ragWebSocketController.notifyAnswerReady(userEmail, queryId); return; } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java b/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java index 3b95db5c..2587b032 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java @@ -40,7 +40,8 @@ public class SearchController { summary = "벡터 검색 + RAG 답변 생성 요청", description = "질문 텍스트를 임베딩 후 pgvector 코사인 유사도 기준 Top-K 문서 청크를 찾아 즉시 " + "반환합니다. AI 답변(answer)은 비동기로 생성되며, 응답 시점에는 ragStatus가 PROCESSING이고 " - + "answer는 null입니다 — 완성되면 WebSocket(/user/queue/rag-answer)으로 알림이 오며, 그 신호를 " + + "answer 필드 자체가 응답 JSON에서 생략됩니다(null이 아니라 키가 없음) — 완성되면 " + + "WebSocket(/user/queue/rag-answer)으로 알림이 오며, 그 신호를 " + "받으면 GET /search/{queryId}로 최신 상태를 다시 조회하세요. 검색 결과가 없으면(NO_CONTEXT) " + "answer가 고정 안내 문구와 함께 즉시(ragStatus=SUCCESS) 반환됩니다. " + "topK 기본값은 5이며 1~20 범위에서 지정할 수 있지만, 서버의 최소 유사도 기준을 " diff --git a/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java index 7980bd88..f6bbe366 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java @@ -39,20 +39,24 @@ public class SearchAnswerQueryService { private final ResponseCitationRepository responseCitationRepository; public SearchResponse getAnswer(Long queryId, Long userId) { + // 1. 소유권 검증 — 본인 것이 아니면 존재 자체를 숨긴다(404). SearchQuery query = searchQueryRepository.findByIdAndUser_Id(queryId, userId) .orElseThrow(() -> new DocGridException(ErrorCode.RAG_ANSWER_NOT_FOUND)); + // 2. 검색 결과 재구성 — 항상 채워진다(RAG 상태와 무관). List savedResults = searchResultRepository.findByQuery_IdOrderByRankNo(query.getId()); List items = new ArrayList<>(); for (int i = 0; i < savedResults.size(); i++) { items.add(SearchResultItem.of(i + 1, VectorSearchCandidate.from(savedResults.get(i)))); } + // 3. RAG 상태 판별 — 아직 PROCESSING이면 answer/citations 없이 바로 반환한다. RagResponse ragResponse = ragResponseRepository.findByQuery_Id(query.getId()).orElse(null); if (ragResponse == null || ragResponse.getStatus() == ResultStatus.PROCESSING) { return new SearchResponse(queryId, items, ResultStatus.PROCESSING, null, List.of()); } + // 4. citation 재구성 — SUCCESS/FAILED 확정된 경우에만 조회한다. List citations = responseCitationRepository .findByResponse_IdOrderByCitationOrder(ragResponse.getId()) .stream() diff --git a/frontend/app/features/SearchPage.tsx b/frontend/app/features/SearchPage.tsx index 0e2a60f2..dfb06cc1 100644 --- a/frontend/app/features/SearchPage.tsx +++ b/frontend/app/features/SearchPage.tsx @@ -24,6 +24,9 @@ export function SearchPage() { const [searching, setSearching] = useState(false); const [error, setError] = useState(""); const pollTimer = useRef(null); + // "지금 화면이 보여주고 있어야 할 queryId"를 별도로 들고 있는다 — refreshAnswer의 응답이 + // 돌아왔을 때 그 사이 사용자가 새 검색을 시작해 이미 낡은 queryId가 됐는지 판별하는 용도다. + const activeQueryIdRef = useRef(null); useEffect(() => { apiRequest("/collections").then(setCollections).catch(() => setCollections([])); @@ -33,16 +36,19 @@ export function SearchPage() { const awaitingAnswer = result?.ragStatus === "PROCESSING"; const refreshAnswer = useCallback(() => { - setResult((current) => { - if (!current) return current; - apiRequest(`/search/${current.queryId}`) - .then(setResult) - .catch(() => { - // 재조회 실패는 조용히 무시한다 — 다음 폴링/push 때 다시 시도된다. 검색 결과는 이미 화면에 - //떠 있으니 사용자에게 굳이 에러를 보여줄 필요가 없다. - }); - return current; - }); + const queryId = activeQueryIdRef.current; + if (queryId === null) return; + apiRequest(`/search/${queryId}`) + .then((response) => { + // 응답이 돌아오는 사이 사용자가 다른 검색을 시작했다면(activeQueryIdRef가 바뀜), 이건 + // 이미 화면과 무관해진 낡은 응답이다 — 새 검색 결과를 덮어쓰지 않도록 버린다. + if (activeQueryIdRef.current !== queryId) return; + setResult(response); + }) + .catch(() => { + // 재조회 실패는 조용히 무시한다 — 다음 폴링/push 때 다시 시도된다. 검색 결과는 이미 화면에 + // 떠 있으니 사용자에게 굳이 에러를 보여줄 필요가 없다. + }); }, []); const socketStatus = useRagAnswerSocket(awaitingAnswer, refreshAnswer); @@ -66,6 +72,9 @@ export function SearchPage() { setQuery(trimmed); setSearching(true); setError(""); + // 새 검색을 시작하는 순간, 이전 queryId를 향해 날아가고 있을지 모르는 refreshAnswer 응답을 + // 전부 무효화한다 — POST 응답이 오기 전까지는 "유효한 활성 queryId가 없는" 상태로 둔다. + activeQueryIdRef.current = null; try { // 검색 결과는 여기서 바로 오지만, AI 답변(answer)은 비동기 생성이라 이 응답엔 아직 없을 수 // 있다(ragStatus: PROCESSING) — 그 경우 아래 useRagAnswerSocket/폴링이 이어받는다. @@ -78,6 +87,7 @@ export function SearchPage() { collectionId: collectionId ? Number(collectionId) : null, }, }); + activeQueryIdRef.current = response.queryId; setResult(response); } catch (reason) { setResult(null); From 7333d71c90f054208a67cdb31046c250a06e4b09 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 14:22:55 +0900 Subject: [PATCH 13/15] =?UTF-8?q?test:=20#218=20CodeRabbit=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=EB=B0=98=EC=98=81=20=E2=80=94=20Worker=20=EC=98=88?= =?UTF-8?q?=EC=99=B8/=EA=B2=BD=ED=95=A9=20=EC=B2=98=EB=A6=AC=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 낙관적 락 경합 시 markUnexpectedFailure를 호출하지 않는지, 한 job이 예외로 실패해도 다음 폴링에서 뒤에 대기 중인 job이 정상 처리되는지 검증하는 케이스를 추가했다. Co-Authored-By: Claude Fable 5 --- .../domain/rag/service/RagJobWorkerTest.java | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java index b1cba7e9..7f3ef3e3 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java @@ -16,12 +16,20 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.OptimisticLockingFailureException; import com.opensource.docgrid.domain.rag.controller.RagWebSocketController; import com.opensource.docgrid.domain.rag.entity.RagResponse; import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; import com.opensource.docgrid.domain.search.enums.ResultStatus; +/** + * RagJobWorker.processNext() 한 사이클의 동작만 검증한다 — 큐 조회(가장 오래된 PROCESSING 하나를 + * 꺼내는지), 처리 위임(RagFacade.processJob()으로 id를 넘기는지), 완료/실패 각각에서 요청자 + * 본인에게만 알림이 가는지가 검증 범위다. 실제 OllamaClient 호출이나 DB 반영 여부(dirty checking이 + * 실제로 먹히는지)는 이 테스트의 목(mock) 구조로는 증명할 수 없어 검증 범위 밖이다 — + * RagJobWorkerIntegrationTest가 그 부분을 담당한다. + */ @ExtendWith(MockitoExtension.class) @DisplayName("RagJobWorker 단위 테스트") class RagJobWorkerTest { @@ -66,8 +74,8 @@ void processNext_pendingJobExists_processesAndNotifiesOwner() { } @Test - @DisplayName("processJob이 예상 밖 예외를 던져도 Worker는 죽지 않고 이번 건만 건너뛴다(push 생략)") - void processNext_unexpectedException_skipsJobWithoutCrashingWorker() { + @DisplayName("processJob이 예상 밖 예외를 던지면 job을 FAILED로 확정하고, Worker는 죽지 않고 이번 건만 건너뛴다") + void processNext_unexpectedException_marksFailedAndSkipsJobWithoutCrashingWorker() { RagResponse job = deepStubJob(999L, 100L, "user@example.com"); given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) .willReturn(Optional.of(job)); @@ -75,9 +83,51 @@ void processNext_unexpectedException_skipsJobWithoutCrashingWorker() { ragJobWorker.processNext(); + // job을 PROCESSING으로 방치하면 Worker가 같은 job을 계속 다시 집어 무한 재시도하게 된다 + // (detached entity 버그와 같은 증상) — 그래서 반드시 FAILED로 확정해야 한다. + then(ragFacade).should(times(1)).markUnexpectedFailure(999L, "예상 밖 버그"); + // FAILED로 확정된 이상 사용자도 결과(비록 실패 안내지만)를 받아야 하므로 알림은 그대로 간다. + then(ragWebSocketController).should(times(1)).notifyAnswerReady("user@example.com", 100L); + } + + @Test + @DisplayName("다른 트랜잭션이 이미 같은 job을 처리했으면(낙관적 락 경합) FAILED로 덮어쓰지 않고 조용히 넘어간다") + void processNext_optimisticLockingFailure_skipsWithoutOverwritingAsFailed() { + RagResponse job = deepStubJob(999L, 100L, "user@example.com"); + given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) + .willReturn(Optional.of(job)); + org.mockito.Mockito.doThrow(new OptimisticLockingFailureException("경합")) + .when(ragFacade).processJob(999L); + + ragJobWorker.processNext(); + + // 다른 트랜잭션이 이미 올바르게 처리한 결과이므로, 이걸 FAILED로 덮어쓰면 정상 처리된 + // 결과를 오답으로 바꿔버리는 2차 사고가 난다 — markUnexpectedFailure를 호출하면 안 된다. + then(ragFacade).should(never()).markUnexpectedFailure(any(), any()); then(ragWebSocketController).should(never()).notifyAnswerReady(any(), any()); } + @Test + @DisplayName("한 job이 예외로 실패해도 다음 폴링에서 뒤에 대기 중인 job이 정상 처리된다") + void processNext_afterUnexpectedFailure_nextPollingProcessesFollowingJob() { + RagResponse failingJob = deepStubJob(1L, 100L, "user1@example.com"); + RagResponse nextJob = deepStubJob(2L, 200L, "user2@example.com"); + org.mockito.Mockito.doThrow(new RuntimeException("예상 밖 버그")).when(ragFacade).processJob(1L); + + given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) + .willReturn(Optional.of(failingJob)); + ragJobWorker.processNext(); // 1번째 폴링: failingJob 실패 → FAILED로 확정됨 + + // FAILED로 확정됐으니 실제 DB에선 이제 findFirst...가 다음 대기 건(nextJob)을 돌려준다 — + // 여기서는 그 상태 변화를 목으로 흉내낸다. + given(ragResponseRepository.findFirstByStatusOrderByCreatedAtAsc(ResultStatus.PROCESSING)) + .willReturn(Optional.of(nextJob)); + ragJobWorker.processNext(); // 2번째 폴링: nextJob은 정상 처리돼야 한다 + + then(ragFacade).should(times(1)).processJob(2L); + then(ragWebSocketController).should(times(1)).notifyAnswerReady("user2@example.com", 200L); + } + private RagResponse deepStubJob(Long jobId, Long queryId, String userEmail) { RagResponse job = mock(RagResponse.class, RETURNS_DEEP_STUBS); given(job.getId()).willReturn(jobId); From b0261c7747b687bf932cde79ae4a601287d47c00 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 14:23:11 +0900 Subject: [PATCH 14/15] =?UTF-8?q?docs:=20#218=20CodeRabbit=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=8E=9C=EC=8A=A4=20=EC=96=B8=EC=96=B4=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MD040 위반(언어 미지정 코드 펜스)을 흐름도·로그·명령 출력 블록에 text/console로 지정해 해소한다. Co-Authored-By: Claude Fable 5 --- docs/design/kangcheolung-#218-async-rag-job-queue.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/design/kangcheolung-#218-async-rag-job-queue.md b/docs/design/kangcheolung-#218-async-rag-job-queue.md index bc03baf0..08fa9350 100644 --- a/docs/design/kangcheolung-#218-async-rag-job-queue.md +++ b/docs/design/kangcheolung-#218-async-rag-job-queue.md @@ -62,7 +62,7 @@ Ollama 호출"이라는 동시성 상한을 자연히 만든다** — 기각한 ## 3. 설계 — 전체 흐름 -``` +```text ① 접수(동기, 빠름) 브라우저 → POST /search → SearchFacade.search() : 벡터 검색 (그대로, 안 바뀜) @@ -215,7 +215,7 @@ public static VectorSearchCandidate from(SearchResult result) { 증상: 상태가 영원히 `PROCESSING`으로 남아 Worker가 같은 queryId를 1.7초 간격으로 무한 재처리 (Ollama를 계속 다시 호출하면서 CPU/GPU를 낭비). 실제 운영 로그: -``` +```text [RAG] done queryId=188 responseId=99 latencyMs=704 [RAG] done queryId=188 responseId=99 latencyMs=2414 [RAG] done queryId=188 responseId=99 latencyMs=728 @@ -354,7 +354,7 @@ void processNext_persistsStatusChangeAcrossDetachedEntityBoundary() { ``` **결과 — 실제 터미널 출력 그대로**: -``` +```console $ ./gradlew test -Dgroups=integration \ --tests "com.opensource.docgrid.domain.rag.integration.RagJobWorkerIntegrationTest" --rerun @@ -396,7 +396,7 @@ await().atMost(Duration.ofSeconds(150)).untilAsserted(() -> { ``` **결과 — 실제 터미널 출력 그대로**: -``` +```console $ ./gradlew test -Dgroups=integration \ --tests "com.opensource.docgrid.domain.rag.integration.RagJobWorkerConcurrentQueueIntegrationTest" --rerun @@ -447,7 +447,7 @@ JUnit 리포트: `RagJobWorkerTest`(단위, Worker의 예외 처리·push 로직)를 신규 추가했다. 프론트 `SearchPage.tsx` 관련 기존 테스트(`search-sources.test.ts`)도 `ragStatus` 필드 추가에 맞춰 갱신했다. -``` +```console $ ./gradlew test # 전체 백엔드 (통합 테스트 제외) BUILD SUCCESSFUL From ee28d82e1b7b6ed5278c342d59b4a314c5d6a2d7 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Mon, 17 Aug 2026 14:40:31 +0900 Subject: [PATCH 15/15] =?UTF-8?q?docs:=20#218=20CodeRabbit=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EB=A1=9C=20=EA=B3=A0=EC=B9=9C=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?3=EA=B1=B4=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=EC=97=90?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markUnexpectedFailure 추가, 낙관적 락 경합 방어, 프론트 stale response 방지 — 각각 원인·실제 로그·수정 코드를 4-11로 기록한다. Co-Authored-By: Claude Fable 5 --- .../kangcheolung-#218-async-rag-job-queue.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/design/kangcheolung-#218-async-rag-job-queue.md b/docs/design/kangcheolung-#218-async-rag-job-queue.md index 08fa9350..b7edaeab 100644 --- a/docs/design/kangcheolung-#218-async-rag-job-queue.md +++ b/docs/design/kangcheolung-#218-async-rag-job-queue.md @@ -333,6 +333,78 @@ const showRawResults = result !== null && result.results.length > 0 7절 참고), (b) 로딩 중 원본 결과 미리보기 자체를 포기하고 답변까지 다 기다렸다가 한 번에 보여주는 방식으로 되돌려야 한다 — 아직 결정 안 함. +### 4-11. PR 리뷰(CodeRabbit)로 발견한 버그 2건 + +#### 1) 예상 못한 예외가 나면 job이 영원히 PROCESSING에 남는 문제 + +`RagJobWorker`는 `processJob()` 내부의 Ollama 관련 실패(`DocGridException`)는 이미 +extractive fallback으로 처리하지만, 그 밖의 예상 못한 예외(버그 등)는 로그만 남기고 그냥 +넘어갔다 — 이 경우 job의 상태가 바뀌지 않은 채 남아, 4-5에서 고친 detached entity 버그와 +증상이 같아진다(Worker가 같은 job을 계속 다시 집어 무한 재시도). + +고침 — `RagFacade`에 `markUnexpectedFailure()`를 추가해, 예상 못한 예외를 잡으면 반드시 +FAILED로 확정한다: +```java +public void markUnexpectedFailure(Long jobId, String errorMessage) { + ragResponseRepository.findById(jobId) + .ifPresent(job -> ragResponseCommandService.completeFailed(job, UNEXPECTED_FAILURE_ANSWER_TEXT, errorMessage)); +} +``` + +#### 2) 여러 Worker가 같은 job을 동시에 집을 수 있는 경합 + +설계는 "Worker 인스턴스 1개"를 전제하지만(2-2 참고), `findFirstByStatusOrderByCreatedAtAsc()` +(조회)와 `processJob()`의 저장(쓰기) 사이에는 락이 없다 — 그 사이에 다른 트랜잭션이 같은 job을 +먼저 처리해버리면 경합이 생긴다. 이론적 우려로 끝나지 않고, 실제로 통합 테스트를 여러 개 +동시에 돌렸을 때(Spring 테스트가 컨텍스트를 여러 개 띄우면서 각자 `@Scheduled` Worker가 뜸, +혹은 테스트 자신의 정리 로직이 아직 처리 중인 row를 지우면서 겹쳤을 가능성) 실제로 재현됐다: +```console +org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction + (or unsaved-value mapping was incorrect): [com.opensource.docgrid.domain.rag.entity.RagResponse#33] +``` + +이때 위 1)번에서 만든 `markUnexpectedFailure()`를 그대로 태우면, **이미 다른 트랜잭션이 올바르게 +처리한 결과를 뒤늦게 FAILED로 덮어써버리는 2차 사고**가 난다. 그래서 `OptimisticLockingFailureException` +(Spring이 이런 종류의 예외를 감싸는 타입)만 따로 잡아 조용히 넘어가도록 분기했다: +```java +try { + ragFacade.processJob(job.getId()); +} catch (OptimisticLockingFailureException e) { + log.warn("[RAG-WORKER] job이 이미 다른 트랜잭션에서 처리된 것으로 보임(경합) queryId={}", queryId); + return; // markUnexpectedFailure를 호출하지 않는다 — 정상 처리된 결과를 오답으로 바꾸면 안 되므로. +} catch (Exception e) { + ... +} +``` +이 방어는 **경합 자체(같은 job을 두 Worker가 동시에 집는 것)를 막지 않는다** — Ollama 중복 +호출 같은 낭비는 여전히 생길 수 있다. 막는 건 그로 인한 데이터 오염(정상 결과를 실패로 덮어씀) +뿐이다. 지금 배포는 인스턴스가 1개뿐이라 평소엔 이 경합 자체가 발생하지 않고, 나중에 배포 +방식이 바뀌는 경우에만 의미가 생기는 안전장치다 — 비용이 예외 하나 추가하는 수준으로 작아서 +지금 넣어뒀다. 완전히 막으려면(`SELECT ... FOR UPDATE SKIP LOCKED` 등 원자적 claim) 더 큰 +작업이 필요해 7절 범위 밖으로 남겼다. + +#### 3) 프론트 — 새 검색이 이전 검색의 뒤늦은 응답에 덮어써지는 경쟁 조건 + +`refreshAnswer()`가 `setResult(current => ...)` 안에서 `current.queryId`를 읽어 GET 요청을 +보내는 구조였는데, 그 요청이 응답으로 돌아올 때까지 사용자가 **다른 검색을 새로 시작**하면, +뒤늦게 도착한 옛 queryId의 응답이 무조건 `setResult()`로 덮어써서 방금 시작한 새 검색 결과를 +지워버릴 수 있었다. + +고침 — `activeQueryIdRef`로 "지금 화면이 보여줘야 할 queryId"를 별도로 추적하고, 응답이 +돌아온 시점에 그 값과 비교해 낡은 응답이면 버린다: +```tsx +const refreshAnswer = useCallback(() => { + const queryId = activeQueryIdRef.current; + if (queryId === null) return; + apiRequest(`/search/${queryId}`).then((response) => { + if (activeQueryIdRef.current !== queryId) return; // 그 사이 다른 검색으로 넘어감 — 버림 + setResult(response); + }); +}, []); +``` +`search()`는 새 검색을 시작하는 즉시 `activeQueryIdRef.current = null`로 초기화해, POST 응답이 +오기 전까지는 어떤 낡은 refresh 응답도 적용되지 않게 막는다. + ## 5. 검증 ### 5-1. `RagJobWorkerIntegrationTest` — detached entity 버그 재현·검증