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/controller/RagWebSocketController.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java
new file mode 100644
index 00000000..4423f1cf
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/controller/RagWebSocketController.java
@@ -0,0 +1,38 @@
+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));
+ }
+
+ // 완료 알림의 최소 트리거 페이로드 — 답변 본문은 담지 않는다. 프론트가 이 이벤트를 받으면
+ // 항상 GET /search/{queryId}로 다시 조회해야 하며, 이 record 자체를 최종 상태로 신뢰하면 안 된다.
+ private record RagAnswerReadyEvent(Long queryId) {
+ }
+}
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/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/RagFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java
index 14d06a1e..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
@@ -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
@@ -42,12 +46,17 @@ 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;
// 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 +67,99 @@ 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());
+ }
+
+ // 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) {
+ return searchResultRepository.findByQuery_IdOrderByRankNo(queryId).stream()
+ .map(VectorSearchCandidate::from)
+ .toList();
}
private String buildExtractiveFallbackAnswer(List candidates) {
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..a1a2b2b9
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagJobWorker.java
@@ -0,0 +1,77 @@
+package com.opensource.docgrid.domain.rag.service;
+
+import java.util.Optional;
+
+import org.springframework.dao.OptimisticLockingFailureException;
+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 (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;
+ }
+
+ ragWebSocketController.notifyAnswerReady(userEmail, queryId);
+ }
+}
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/controller/SearchController.java b/backend/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java
index f2dc8b33..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
@@ -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,20 @@ 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 필드 자체가 응답 JSON에서 생략됩니다(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 +55,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/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/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/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/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..f6bbe366
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/search/service/query/SearchAnswerQueryService.java
@@ -0,0 +1,68 @@
+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) {
+ // 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()
+ .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/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
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(
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}
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;
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..7f3ef3e3
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagJobWorkerTest.java
@@ -0,0 +1,138 @@
+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 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 {
+
+ @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이 예상 밖 예외를 던지면 job을 FAILED로 확정하고, Worker는 죽지 않고 이번 건만 건너뛴다")
+ void processNext_unexpectedException_marksFailedAndSkipsJobWithoutCrashingWorker() {
+ 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();
+
+ // 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);
+ 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) {
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..b7edaeab
--- /dev/null
+++ b/docs/design/kangcheolung-#218-async-rag-job-queue.md
@@ -0,0 +1,552 @@
+# 검색-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. 설계 — 전체 흐름
+
+```text
+① 접수(동기, 빠름)
+브라우저 → 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를 낭비). 실제 운영 로그:
+```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
+... (동일 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) 로딩 중 원본 결과 미리보기 자체를 포기하고 답변까지 다 기다렸다가 한 번에
+보여주는 방식으로 되돌려야 한다 — 아직 결정 안 함.
+
+### 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 버그 재현·검증
+
+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();
+}
+```
+
+**결과 — 실제 터미널 출력 그대로**:
+```console
+$ ./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));
+});
+```
+
+**결과 — 실제 터미널 출력 그대로**:
+```console
+$ ./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` 필드 추가에 맞춰 갱신했다.
+
+```console
+$ ./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초 폴백 폴링으로 넘어가지만,
+ 재연결 자체를 시도하는 로직은 없다(페이지 새로고침 전까지는 폴링에 의존).
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` 참조.
diff --git a/frontend/app/features/SearchPage.tsx b/frontend/app/features/SearchPage.tsx
index b526eebd..dfb06cc1 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,18 +23,61 @@ export function SearchPage() {
const [result, setResult] = useState(null);
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([]));
}, []);
+ // AI 답변이 아직 생성 중일 때만 true — WebSocket과 폴백 폴링을 이때만 연다.
+ const awaitingAnswer = result?.ragStatus === "PROCESSING";
+
+ const refreshAnswer = useCallback(() => {
+ 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);
+
+ 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;
setQuery(trimmed);
setSearching(true);
setError("");
+ // 새 검색을 시작하는 순간, 이전 queryId를 향해 날아가고 있을지 모르는 refreshAnswer 응답을
+ // 전부 무효화한다 — POST 응답이 오기 전까지는 "유효한 활성 queryId가 없는" 상태로 둔다.
+ activeQueryIdRef.current = null;
try {
+ // 검색 결과는 여기서 바로 오지만, AI 답변(answer)은 비동기 생성이라 이 응답엔 아직 없을 수
+ // 있다(ragStatus: PROCESSING) — 그 경우 아래 useRagAnswerSocket/폴링이 이어받는다.
const response = await apiRequest("/search", {
method: "POST",
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
@@ -41,6 +87,7 @@ export function SearchPage() {
collectionId: collectionId ? Number(collectionId) : null,
},
});
+ activeQueryIdRef.current = response.queryId;
setResult(response);
} catch (reason) {
setResult(null);
@@ -58,6 +105,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 +127,38 @@ export function SearchPage() {
:
{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;
+}
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 },