From 189a018483254a5f325411f6b9ce50cb48861632 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:06:17 +0900 Subject: [PATCH 01/14] =?UTF-8?q?[Feat]=20RagResponse=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=ED=95=84=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llmProvider/llmModelName이 각각 무엇을 저장하는 필드인지 명시한다. --- .../com/opensource/docgrid/domain/rag/entity/RagResponse.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java b/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java index 267ebf5..db00255 100644 --- a/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java +++ b/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java @@ -57,9 +57,11 @@ public class RagResponse extends BaseEntity { @Column(name = "answer_text", nullable = false, columnDefinition = "TEXT") private String answerText; + // LLM 제공자 이름 (Ollama) @Column(name = "llm_provider", length = 50) private String llmProvider; + // LLM 모델 이름 (qwen2.5:3b) @Column(name = "llm_model_name", length = 100) private String llmModelName; From 5f048608126f7f39bc4d8fb0e8f0821c2861aa02 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:06:26 +0900 Subject: [PATCH 02/14] =?UTF-8?q?[Feat]=20SearchResultCommandService.saveA?= =?UTF-8?q?ll()=20=EB=B0=98=ED=99=98=20=ED=83=80=EC=9E=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit void -> List. 저장된 SearchResult의 id를 호출한 쪽이 받을 수 있게 해서, RagFacade가 response_citations.search_result_id를 채울 수 있게 한다 (Issue 4에서 예고한 변경). --- .../search/service/command/SearchResultCommandService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandService.java b/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandService.java index 229923c..b90d8ec 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandService.java +++ b/src/main/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandService.java @@ -30,7 +30,7 @@ public class SearchResultCommandService { private final SearchResultRepository searchResultRepository; private final EntityManager entityManager; - public void saveAll(SearchQuery searchQuery, List candidates) { + public List saveAll(SearchQuery searchQuery, List candidates) { List results = new ArrayList<>(); for (int i = 0; i < candidates.size(); i++) { VectorSearchCandidate c = candidates.get(i); @@ -44,6 +44,6 @@ public void saveAll(SearchQuery searchQuery, List candida .matchedText(c.chunkText()) .build()); } - searchResultRepository.saveAll(results); + return searchResultRepository.saveAll(results); } } From ee5fb1eecf10dabd163826eb6c9cb21a2b90d965 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:06:35 +0900 Subject: [PATCH 03/14] =?UTF-8?q?[Feat]=20SearchOutcome/CitationResponse?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20SearchResponse=EC=97=90=20answer+citat?= =?UTF-8?q?ions=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchOutcome: SearchFacade가 SearchController에게 candidates/savedResults를 함께 넘기기 위한 내부 전달용 객체 (API로 노출되지 않음). CitationResponse: 최종 응답에 노출되는 출처 1건. RagFacade(rag 도메인)가 이미 search 도메인에 의존하고 있어, 순환 참조를 피하기 위해 이 타입을 rag가 아닌 search 도메인에 둔다. SearchResponse는 answer/citations 필드와 병합용 withAnswer()를 추가하고, 기존 results 필드는 하위 호환을 위해 그대로 유지한다. --- .../domain/search/dto/SearchOutcome.java | 19 ++++++++++++++ .../search/dto/response/CitationResponse.java | 25 +++++++++++++++++++ .../search/dto/response/SearchResponse.java | 12 ++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/opensource/docgrid/domain/search/dto/SearchOutcome.java create mode 100644 src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java diff --git a/src/main/java/com/opensource/docgrid/domain/search/dto/SearchOutcome.java b/src/main/java/com/opensource/docgrid/domain/search/dto/SearchOutcome.java new file mode 100644 index 0000000..8ccfc27 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/search/dto/SearchOutcome.java @@ -0,0 +1,19 @@ +package com.opensource.docgrid.domain.search.dto; + +import java.util.List; + +import com.opensource.docgrid.domain.search.dto.response.SearchResponse; +import com.opensource.docgrid.domain.search.entity.SearchResult; + +/** + * SearchFacade가 SearchController에게 검색 결과와 함께 넘겨주는 내부 전달용 객체 (API 응답 아님). + * + *

candidates/savedResults는 RagFacade.generate() 호출에 필요하다 — SearchResponse(API 응답)는 + * 표시용 SearchResultItem만 담고 있어 chunk/document id, 저장된 SearchResult의 id가 없다. + */ +public record SearchOutcome( + SearchResponse response, + List candidates, + List savedResults +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java b/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java new file mode 100644 index 0000000..a05d597 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/search/dto/response/CitationResponse.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.search.dto.response; + +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CitationResponse( + @Schema(description = "인용 라벨") String label, + @Schema(description = "출처 문서 ID") Long documentId, + @Schema(description = "출처 문서 제목") String documentTitle, + @Schema(description = "근거 chunk ID") Long chunkId, + @Schema(description = "원본 문서 페이지 번호, 페이지 개념이 없는 형식은 null") Integer pageNo, + @Schema(description = "인용된 텍스트") String quotedText +) { + public static CitationResponse of(int order, VectorSearchCandidate candidate) { + return new CitationResponse( + "[" + order + "]", + candidate.documentId(), + candidate.documentTitle(), + candidate.chunkId(), + candidate.pageNo(), + candidate.chunkText() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java b/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java index 35e3d93..87ec86c 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java +++ b/src/main/java/com/opensource/docgrid/domain/search/dto/response/SearchResponse.java @@ -8,17 +8,23 @@ public record SearchResponse( @Schema(description = "검색 요청 ID (search_queries.id)") Long queryId, - @Schema(description = "검색 결과 목록 (유사도 내림차순)") List results + @Schema(description = "검색 결과 목록 (유사도 내림차순)") List results, + @Schema(description = "RAG로 생성된 답변, 아직 생성 전이면 null") String answer, + @Schema(description = "답변의 근거 출처 목록") List citations ) { public static SearchResponse of(Long queryId, List candidates) { List items = new java.util.ArrayList<>(); for (int i = 0; i < candidates.size(); i++) { items.add(SearchResultItem.of(i + 1, candidates.get(i))); } - return new SearchResponse(queryId, List.copyOf(items)); + return new SearchResponse(queryId, List.copyOf(items), null, List.of()); } public static SearchResponse empty(Long queryId) { - return new SearchResponse(queryId, List.of()); + return new SearchResponse(queryId, List.of(), null, List.of()); + } + + public SearchResponse withAnswer(String answer, List citations) { + return new SearchResponse(queryId, results, answer, citations); } } From f7f8cb8dbd578310df8718208185aaece99a24be Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:06:44 +0900 Subject: [PATCH 04/14] =?UTF-8?q?[Feat]=20SearchFacade=EA=B0=80=20SearchOu?= =?UTF-8?q?tcome=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 반환 타입을 SearchResponse -> SearchOutcome으로 변경. 검색 로직 자체는 변경 없고, candidates/savedResults를 함께 실어 반환하도록 두 반환 지점만 수정한다. --- .../docgrid/domain/search/service/SearchFacade.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java b/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java index 1f418d9..4cdf7ab 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java +++ b/src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java @@ -10,10 +10,12 @@ import com.opensource.docgrid.domain.embedding.dto.EmbedResult; import com.opensource.docgrid.domain.embedding.service.query.QueryEmbeddingService; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +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.entity.SearchQuery; +import com.opensource.docgrid.domain.search.entity.SearchResult; import com.opensource.docgrid.domain.search.service.command.SearchQueryCommandService; import com.opensource.docgrid.domain.search.service.command.SearchResultCommandService; import com.opensource.docgrid.domain.search.service.query.AccessibleDocumentQueryService; @@ -57,7 +59,7 @@ public class SearchFacade { private final UserRepository userRepository; private final CollectionRepository collectionRepository; - public SearchResponse search(Long userId, SearchRequest request) { + public SearchOutcome search(Long userId, SearchRequest request) { long start = System.currentTimeMillis(); // 1. 질문 임베딩 @@ -84,7 +86,7 @@ public SearchResponse search(Long userId, SearchRequest request) { log.info("[SEARCH] no accessible documents userId={}", userId); int latency = latencyMs(start); searchQueryCommandService.markSuccess(searchQuery, latency); - return SearchResponse.empty(searchQuery.getId()); + return new SearchOutcome(SearchResponse.empty(searchQuery.getId()), List.of(), List.of()); } // 5. pgvector Top-K 후보 추출 (F-SEARCH-05) @@ -102,13 +104,13 @@ public SearchResponse search(Long userId, SearchRequest request) { userId, candidates.size(), verified.size(), latencyMs(liveStart)); // 7. search_results 저장 (F-SEARCH-07) - searchResultCommandService.saveAll(searchQuery, verified); + List savedResults = searchResultCommandService.saveAll(searchQuery, verified); int latency = latencyMs(start); searchQueryCommandService.markSuccess(searchQuery, latency); log.info("[SEARCH] done queryId={} results={} latencyMs={}", searchQuery.getId(), verified.size(), latency); - return SearchResponse.of(searchQuery.getId(), verified); + return new SearchOutcome(SearchResponse.of(searchQuery.getId(), verified), verified, savedResults); } catch (Exception e) { searchQueryCommandService.markFailed(searchQuery, e.getMessage()); From b873d650fdb72a5c31eecf8dbd9d5b0c74895261 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:06:52 +0900 Subject: [PATCH 05/14] =?UTF-8?q?[Feat]=20RagResponseCommandService?= =?UTF-8?q?=EC=97=90=20createNoContext=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 결과가 0건이라 LLM 호출을 생략한 경우, citation 없이 고정 안내 문구로 status=SUCCESS 기록을 남긴다. createFailed()의 고정 문구 패턴과 대칭되게 구현한다. --- .../service/command/RagResponseCommandService.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java b/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java index b7e043c..5c9cc66 100644 --- a/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java +++ b/src/main/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandService.java @@ -26,6 +26,8 @@ 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; @@ -44,6 +46,16 @@ public RagResponse createSuccess(SearchQuery query, String promptText, OllamaGen return ragResponseRepository.save(ragResponse); } + // 검색 결과가 0건이라 LLM 호출 자체를 생략한 경우. citation 없이 고정 문구로 SUCCESS 기록한다. + public RagResponse createNoContext(SearchQuery query) { + RagResponse ragResponse = RagResponse.builder() + .query(query) + .answerText(NO_CONTEXT_ANSWER_TEXT) + .status(ResultStatus.SUCCESS) + .build(); + return ragResponseRepository.save(ragResponse); + } + // REQUIRES_NEW: 상위 트랜잭션이 롤백돼도 FAILED 기록은 독립 트랜잭션으로 저장된다. @Transactional(propagation = Propagation.REQUIRES_NEW) public RagResponse createFailed(SearchQuery query, String promptText, String errorMessage) { From 2b68f9ec677538577a8124afc46acc33acea7f03 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:07:01 +0900 Subject: [PATCH 06/14] =?UTF-8?q?[Feat]=20ResponseCitationCommandService?= =?UTF-8?q?=EC=97=90=20search=5Fresult=5Fid=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveAll()에 List 파라미터를 추가해 Issue 4에서 비워뒀던 search_result_id를 채운다. candidates와 searchResults는 SearchResultCommandService.saveAll()이 동일한 순서로 만든 것이라는 전제로 인덱스 기반 1:1 매핑한다. --- .../service/command/ResponseCitationCommandService.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java b/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java index 5a784b1..a690d23 100644 --- a/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java +++ b/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java @@ -11,6 +11,7 @@ import com.opensource.docgrid.domain.rag.entity.ResponseCitation; import com.opensource.docgrid.domain.rag.repository.ResponseCitationRepository; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.entity.SearchResult; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -19,8 +20,7 @@ * 응답 출처(citation) 저장 서비스 (F-RAG-04). * *

PromptBuilder가 프롬프트에 포함시킨 것과 동일한 candidates 순서를 citation_order/citation_label - * 근거로 그대로 재사용한다. search_result_id는 이번 이슈에서 채우지 않는다(null) — SearchResultCommandService.saveAll()이 - * 저장된 SearchResult를 반환하지 않아 이 시점에는 알 수 없고, Issue 5에서 채운다. + * 근거로 그대로 재사용한다. search_result_id는 searchResults 인자로 함께 받아 연결한다. */ @Transactional @Service @@ -30,13 +30,15 @@ public class ResponseCitationCommandService { private final ResponseCitationRepository responseCitationRepository; private final EntityManager entityManager; - public void saveAll(RagResponse response, List candidates) { + // candidates와 searchResults는 SearchResultCommandService.saveAll()이 동일한 순서로 만든 것이므로 인덱스로 1:1 대응한다. + public void saveAll(RagResponse response, List candidates, List searchResults) { List citations = new ArrayList<>(); for (int i = 0; i < candidates.size(); i++) { VectorSearchCandidate c = candidates.get(i); citations.add(ResponseCitation.builder() .response(response) .chunk(entityManager.getReference(DocumentChunk.class, c.chunkId())) + .searchResult(searchResults.get(i)) .citationOrder(i + 1) .citationLabel("[" + (i + 1) + "]") .quotedText(c.chunkText()) From 636cf281c3dfa093e92b04e7e0a27bbca962a511 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:07:11 +0900 Subject: [PATCH 07/14] =?UTF-8?q?[Feat]=20RagAnswer,=20RagFacade=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(F-RAG-05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1~4에서 만든 PromptBuilder/OllamaClient/RagResponseCommandService/ ResponseCitationCommandService를 순서대로 호출하는 조율자. SearchFacade와 별도 트랜잭션으로 분리해, Ollama HTTP 호출이 검색 DB 작업과 같은 커넥션을 오래 물고 있지 않게 한다. candidates가 비어있으면(NO_CONTEXT) LLM 호출을 생략하고, 실패 시 createFailed()로 기록 후 예외를 재전파해 GlobalExceptionHandler가 503으로 변환하게 한다. --- .../docgrid/domain/rag/dto/RagAnswer.java | 25 +++++++ .../docgrid/domain/rag/service/RagFacade.java | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/rag/dto/RagAnswer.java create mode 100644 src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java diff --git a/src/main/java/com/opensource/docgrid/domain/rag/dto/RagAnswer.java b/src/main/java/com/opensource/docgrid/domain/rag/dto/RagAnswer.java new file mode 100644 index 0000000..cc3afbe --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/rag/dto/RagAnswer.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.rag.dto; + +import java.util.ArrayList; +import java.util.List; + +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.dto.response.CitationResponse; + +/** + * RagFacade.generate()의 반환 타입. SearchController가 이 값을 SearchResponse에 병합한다. + */ +public record RagAnswer(String answerText, List citations) { + + public static RagAnswer of(String answerText, List candidates) { + List items = new ArrayList<>(); + for (int i = 0; i < candidates.size(); i++) { + items.add(CitationResponse.of(i + 1, candidates.get(i))); + } + return new RagAnswer(answerText, List.copyOf(items)); + } + + public static RagAnswer noContext(String answerText) { + return new RagAnswer(answerText, List.of()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java b/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java new file mode 100644 index 0000000..b364118 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java @@ -0,0 +1,73 @@ +package com.opensource.docgrid.domain.rag.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; +import com.opensource.docgrid.domain.rag.dto.RagAnswer; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +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.global.exception.DocGridException; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RAG 답변 생성 전체 흐름을 조율하는 Facade (F-RAG-05). + * + *

+ * 1. candidates가 비어있으면(NO_CONTEXT) LLM 호출 없이 고정 응답 저장
+ * 2. PromptBuilder로 프롬프트 조립
+ * 3. OllamaClient 호출
+ * 4. rag_responses 저장 (성공/실패)
+ * 5. 성공 시 response_citations 저장
+ * 
+ * + *

SearchFacade와 별도 트랜잭션으로 분리되어 있다(SearchController가 순차 호출) — 검색 DB 작업과 + * LLM HTTP 호출을 포함한 RAG DB 작업이 하나의 커넥션을 오래 물고 있지 않도록 하기 위함이다. + */ +@Transactional +@Service +@RequiredArgsConstructor +@Slf4j +public class RagFacade { + + private final PromptBuilder promptBuilder; + private final OllamaClient ollamaClient; + private final RagResponseCommandService ragResponseCommandService; + private final ResponseCitationCommandService responseCitationCommandService; + private final EntityManager entityManager; + + public RagAnswer generate( + Long queryId, String queryText, List candidates, List searchResults + ) { + SearchQuery queryRef = entityManager.getReference(SearchQuery.class, queryId); + + // 검색 후보가 없으면(NO_CONTEXT) LLM 호출 없이 고정 응답 저장 + if (candidates.isEmpty()) { + RagResponse ragResponse = ragResponseCommandService.createNoContext(queryRef); + log.info("[RAG] no context queryId={} responseId={}", queryId, ragResponse.getId()); + return RagAnswer.noContext(ragResponse.getAnswerText()); + } + + // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 + String prompt = promptBuilder.build(queryText, candidates); + try { + OllamaGenerateResult result = ollamaClient.generate(prompt); + RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); + responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); + log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); + return RagAnswer.of(result.answerText(), candidates); + } catch (DocGridException e) { + ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage()); + throw e; + } + } +} From 41850a5dcddb0076438b0d406678820d70ac176a Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:07:21 +0900 Subject: [PATCH 08/14] =?UTF-8?q?[Feat]=20SearchController=EC=97=90=20RagF?= =?UTF-8?q?acade=20=EC=97=B0=EA=B2=B0=20=E2=80=94=20=EC=B5=9C=EC=A2=85=20a?= =?UTF-8?q?nswer+citations=20=EC=9D=91=EB=8B=B5=20=EC=A1=B0=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchFacade.search() 완료 후 ragFacade.generate()를 순차 호출하고, SearchResponse.withAnswer()로 병합해 반환한다. RAG 블록은 자기만의 엔드포인트를 갖지 않고 기존 POST /search를 확장하는 구조다. --- .../search/controller/SearchController.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java b/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java index c279335..30fc9ad 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java +++ b/src/main/java/com/opensource/docgrid/domain/search/controller/SearchController.java @@ -7,6 +7,9 @@ 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.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.service.SearchFacade; @@ -26,19 +29,25 @@ public class SearchController { private final SearchFacade searchFacade; + private final RagFacade ragFacade; @Operation( - summary = "벡터 검색", - description = "질문 텍스트를 임베딩 후 pgvector 코사인 유사도 기준 Top-K 문서 청크를 반환합니다. " + summary = "벡터 검색 + RAG 답변 생성", + description = "질문 텍스트를 임베딩 후 pgvector 코사인 유사도 기준 Top-K 문서 청크를 찾고, " + + "그 청크를 근거로 LLM이 생성한 답변(answer)과 출처(citations)를 함께 반환합니다. " + "topK 기본값은 5이며 1~20 범위에서 지정할 수 있습니다. " + "collectionId를 지정하면 해당 컬렉션 내 문서로 검색 범위를 좁힙니다. " - + "권한이 없는 문서는 결과에 포함되지 않으며, 접근 가능한 문서가 없으면 빈 배열을 반환합니다." + + "권한이 없는 문서는 결과에 포함되지 않으며, 접근 가능한 문서가 없으면 answer에 고정 안내 문구가 반환됩니다." ) @PostMapping public ResponseEntity> search( @Parameter(hidden = true) @CurrentUser Long userId, @RequestBody @Valid SearchRequest request ) { - return ResponseUtils.ok(searchFacade.search(userId, request)); + SearchOutcome outcome = searchFacade.search(userId, request); + RagAnswer ragAnswer = ragFacade.generate( + outcome.response().queryId(), request.queryText(), outcome.candidates(), outcome.savedResults() + ); + return ResponseUtils.ok(outcome.response().withAnswer(ragAnswer.answerText(), ragAnswer.citations())); } } From e77c1e31871b1f4266e497dd6f90e8eed46c8e5d Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:07:28 +0900 Subject: [PATCH 09/14] =?UTF-8?q?[Test]=20=EA=B2=80=EC=83=89=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20SearchOutcom?= =?UTF-8?q?e=20=EB=B0=98=ED=99=98=20=ED=83=80=EC=9E=85=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchFacadeTest 3개 테스트를 SearchOutcome 접근 경로(outcome.response())로 수정하고, SearchResultCommandServiceTest에 saveAll() 반환값 검증을 추가한다. --- .../search/service/SearchFacadeTest.java | 21 ++++++++++++------- .../SearchResultCommandServiceTest.java | 3 ++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java b/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java index fc6f0cf..d638cb2 100644 --- a/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java +++ b/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java @@ -28,9 +28,9 @@ import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; import com.opensource.docgrid.domain.embedding.service.query.QueryEmbeddingService; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +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.entity.SearchQuery; import com.opensource.docgrid.domain.search.fixture.SearchQueryFixture; import com.opensource.docgrid.domain.search.service.command.SearchQueryCommandService; @@ -73,11 +73,13 @@ void search_normalFlow_returnsResults() { given(accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null)).willReturn(List.of(3L)); given(vectorSearchQueryService.search(any(), any(), any(), anyInt())).willReturn(List.of(candidate)); given(permissionQueryService.canReadDocument(USER_ID, 3L)).willReturn(true); + given(searchResultCommandService.saveAll(any(), any())).willReturn(List.of()); - SearchResponse response = searchFacade.search(USER_ID, REQUEST); + SearchOutcome outcome = searchFacade.search(USER_ID, REQUEST); - assertThat(response.results()).hasSize(1); - assertThat(response.results().get(0).rank()).isEqualTo(1); + assertThat(outcome.response().results()).hasSize(1); + assertThat(outcome.response().results().get(0).rank()).isEqualTo(1); + assertThat(outcome.candidates()).hasSize(1); then(searchResultCommandService).should(times(1)).saveAll(any(), any()); then(searchQueryCommandService).should(times(1)).markSuccess(any(), anyInt()); } @@ -95,9 +97,10 @@ void search_noPermittedIds_skipsVectorSearch() { .willReturn(searchQuery); given(accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null)).willReturn(List.of()); - SearchResponse response = searchFacade.search(USER_ID, REQUEST); + SearchOutcome outcome = searchFacade.search(USER_ID, REQUEST); - assertThat(response.results()).isEmpty(); + assertThat(outcome.response().results()).isEmpty(); + assertThat(outcome.candidates()).isEmpty(); then(vectorSearchQueryService).should(never()).search(any(), anyLong(), any(), anyInt()); then(searchQueryCommandService).should(times(1)).markSuccess(any(), anyInt()); } @@ -117,10 +120,12 @@ void search_liveCheckFiltersOut_excludesCandidate() { given(accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null)).willReturn(List.of(3L)); given(vectorSearchQueryService.search(any(), any(), any(), anyInt())).willReturn(List.of(candidate)); given(permissionQueryService.canReadDocument(USER_ID, 3L)).willReturn(false); + given(searchResultCommandService.saveAll(any(), any())).willReturn(List.of()); - SearchResponse response = searchFacade.search(USER_ID, REQUEST); + SearchOutcome outcome = searchFacade.search(USER_ID, REQUEST); - assertThat(response.results()).isEmpty(); + assertThat(outcome.response().results()).isEmpty(); + assertThat(outcome.candidates()).isEmpty(); then(searchResultCommandService).should(times(1)).saveAll(any(), any()); then(searchQueryCommandService).should(times(1)).markSuccess(any(), anyInt()); } diff --git a/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandServiceTest.java index b4f1ecf..121c7cb 100644 --- a/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/search/service/command/SearchResultCommandServiceTest.java @@ -52,7 +52,7 @@ void saveAll_savesWithRank() { given(entityManager.getReference(eq(Embedding.class), any())).willReturn(null); given(searchResultRepository.saveAll(any())).willAnswer(i -> i.getArgument(0)); - searchResultCommandService.saveAll(query, List.of(c1, c2)); + List returned = searchResultCommandService.saveAll(query, List.of(c1, c2)); ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); then(searchResultRepository).should(times(1)).saveAll(captor.capture()); @@ -62,6 +62,7 @@ void saveAll_savesWithRank() { assertThat(saved.get(0).getRankNo()).isEqualTo(1); assertThat(saved.get(1).getRankNo()).isEqualTo(2); assertThat(saved.get(0).getSimilarityScore()).isEqualByComparingTo(new BigDecimal("0.9")); + assertThat(returned).isEqualTo(saved); } @Test From d5b44504d0c336690eba84dd6ad3546e084da0cd Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:07:37 +0900 Subject: [PATCH 10/14] =?UTF-8?q?[Test]=20RAG=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?RagFacadeTest=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createNoContext 테스트 추가, ResponseCitationCommandServiceTest를 변경된 saveAll() 시그니처(searchResults 인자)에 맞춰 수정하고 search_result_id가 실제로 연결되는지 검증한다. RagFacadeTest는 NO_CONTEXT/정상/실패 3가지 흐름을 검증한다. --- .../domain/rag/service/RagFacadeTest.java | 140 ++++++++++++++++++ .../RagResponseCommandServiceTest.java | 16 ++ .../ResponseCitationCommandServiceTest.java | 10 +- 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java diff --git a/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java b/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java new file mode 100644 index 0000000..f9155e5 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java @@ -0,0 +1,140 @@ +package com.opensource.docgrid.domain.rag.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +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 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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; +import com.opensource.docgrid.domain.rag.dto.RagAnswer; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +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.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import jakarta.persistence.EntityManager; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("RagFacade 단위 테스트") +class RagFacadeTest { + + @InjectMocks + private RagFacade ragFacade; + + @Mock + private PromptBuilder promptBuilder; + + @Mock + private OllamaClient ollamaClient; + + @Mock + private RagResponseCommandService ragResponseCommandService; + + @Mock + private ResponseCitationCommandService responseCitationCommandService; + + @Mock + private EntityManager entityManager; + + private static final Long QUERY_ID = 100L; + + @Test + @DisplayName("NO_CONTEXT: 후보가 없으면 LLM 호출 없이 고정 응답을 저장한다") + void generate_noCandidates_skipsLlmAndSavesFixedAnswer() { + SearchQuery queryRef = mock(SearchQuery.class); + given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); + RagResponse noContextResponse = RagResponse.builder() + .answerText("관련 문서를 찾지 못했습니다.") + .status(ResultStatus.SUCCESS) + .build(); + given(ragResponseCommandService.createNoContext(queryRef)).willReturn(noContextResponse); + + RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", List.of(), List.of()); + + assertThat(answer.answerText()).isEqualTo("관련 문서를 찾지 못했습니다."); + assertThat(answer.citations()).isEmpty(); + then(promptBuilder).should(never()).build(anyString(), any()); + then(ollamaClient).should(never()).generate(anyString()); + then(ragResponseCommandService).should(times(1)).createNoContext(queryRef); + } + + @Test + @DisplayName("정상 흐름: 프롬프트 조립 후 Ollama 호출, rag_responses/citations 저장, answer+citations를 반환한다") + void generate_success_savesResponseAndCitations() { + 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(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); + + RagAnswer answer = ragFacade.generate(QUERY_ID, "연차 규정 알려줘", candidates, searchResults); + + 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()); + } + + @Test + @DisplayName("Ollama 호출 실패: FAILED로 기록하고 예외를 다시 던진다") + void generate_ollamaFails_savesFailedAndRethrows() { + 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(candidate); + + given(promptBuilder.build(anyString(), eq(candidates))).willReturn("조립된 프롬프트"); + given(ollamaClient.generate("조립된 프롬프트")) + .willThrow(new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE)); + + assertThatThrownBy(() -> ragFacade.generate(QUERY_ID, "질문", candidates, List.of())) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.RAG_SERVICE_UNAVAILABLE); + + then(ragResponseCommandService).should(times(1)) + .createFailed(eq(queryRef), eq("조립된 프롬프트"), anyString()); + then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java index 07f45b7..ba9f23f 100644 --- a/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/rag/service/command/RagResponseCommandServiceTest.java @@ -73,4 +73,20 @@ void createFailed_savesWithFailedStatus() { assertThat(saved.getErrorMessage()).isEqualTo("Ollama 서버 연결 실패"); assertThat(saved.getLlmProvider()).isEqualTo("Ollama"); } + + @Test + @DisplayName("createNoContext: SUCCESS 상태로 고정 안내 문구를 저장한다") + void createNoContext_savesWithFixedAnswer() { + SearchQuery query = SearchQueryFixture.createProcessing(); + given(ragResponseRepository.save(any(RagResponse.class))).willAnswer(i -> i.getArgument(0)); + + ragResponseCommandService.createNoContext(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("관련 문서를 찾지 못했습니다."); + } } diff --git a/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java index 4116abe..9f90120 100644 --- a/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java @@ -23,6 +23,7 @@ import com.opensource.docgrid.domain.rag.entity.ResponseCitation; import com.opensource.docgrid.domain.rag.repository.ResponseCitationRepository; import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.entity.SearchResult; import com.opensource.docgrid.domain.search.enums.ResultStatus; import jakarta.persistence.EntityManager; @@ -51,12 +52,14 @@ void saveAll_savesWithOrderAndLabel() { VectorSearchCandidate c2 = candidate(20L, "복지정책 내용", 3, new BigDecimal("0.8")); DocumentChunk chunk1 = mock(DocumentChunk.class); DocumentChunk chunk2 = mock(DocumentChunk.class); + SearchResult searchResult1 = mock(SearchResult.class); + SearchResult searchResult2 = mock(SearchResult.class); given(entityManager.getReference(DocumentChunk.class, c1.chunkId())).willReturn(chunk1); given(entityManager.getReference(DocumentChunk.class, c2.chunkId())).willReturn(chunk2); given(responseCitationRepository.saveAll(any())).willAnswer(i -> i.getArgument(0)); - responseCitationCommandService.saveAll(response, List.of(c1, c2)); + responseCitationCommandService.saveAll(response, List.of(c1, c2), List.of(searchResult1, searchResult2)); ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); then(responseCitationRepository).should(times(1)).saveAll(captor.capture()); @@ -68,11 +71,12 @@ void saveAll_savesWithOrderAndLabel() { assertThat(saved.get(0).getQuotedText()).isEqualTo("인사규정 내용"); assertThat(saved.get(0).getPageNo()).isEqualTo(12); assertThat(saved.get(0).getRelevanceScore()).isEqualByComparingTo(new BigDecimal("0.9")); - assertThat(saved.get(0).getSearchResult()).isNull(); + assertThat(saved.get(0).getSearchResult()).isSameAs(searchResult1); assertThat(saved.get(0).getChunk()).isSameAs(chunk1); assertThat(saved.get(1).getCitationOrder()).isEqualTo(2); assertThat(saved.get(1).getCitationLabel()).isEqualTo("[2]"); assertThat(saved.get(1).getChunk()).isSameAs(chunk2); + assertThat(saved.get(1).getSearchResult()).isSameAs(searchResult2); } @Test @@ -84,7 +88,7 @@ void saveAll_emptyCandidates_savesEmptyList() { .build(); given(responseCitationRepository.saveAll(any())).willAnswer(i -> i.getArgument(0)); - responseCitationCommandService.saveAll(response, List.of()); + responseCitationCommandService.saveAll(response, List.of(), List.of()); ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); then(responseCitationRepository).should(times(1)).saveAll(captor.capture()); From bd5aabff0ab26bb2a8ff92b932c408bd988b0199 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:07:43 +0900 Subject: [PATCH 11/14] =?UTF-8?q?[Docs]=20RAG=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EC=99=84=EC=84=B1=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #75 --- ...kangcheolung-#75-rag-facade-integration.md | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/design/kangcheolung-#75-rag-facade-integration.md diff --git a/docs/design/kangcheolung-#75-rag-facade-integration.md b/docs/design/kangcheolung-#75-rag-facade-integration.md new file mode 100644 index 0000000..ffa0f5a --- /dev/null +++ b/docs/design/kangcheolung-#75-rag-facade-integration.md @@ -0,0 +1,323 @@ +# #75 RAG 블록 완성 — RagFacade 연결 및 최종 answer + citations 응답 조합 (F-RAG-05) + +closes #75 + +--- + +## 배경 + +Issue 1~4에서 `PromptBuilder`, `OllamaClient`, `RagResponseCommandService`, `ResponseCitationCommandService`를 만들었지만, 전부 서로 연결되지 않은 독립 부품이었다. 이번 이슈는 이 4개를 `RagFacade`로 묶고, 기존 검색 엔드포인트(`POST /search`)에 연결해 **RAG 블록(F-RAG-01~05) 전체를 완성**한다. + +| Issue | 범위 | 상태 | +|---|---|---| +| 1 | Ollama 로컬 세팅 + PromptBuilder (F-RAG-01) | 완료 | +| 2 | OllamaClient 연동 (F-RAG-02) | 완료 | +| 3 | rag_responses 저장 (F-RAG-03) | 완료 | +| 4 | response_citations 저장 (F-RAG-04) | 완료 | +| **5 (이 문서)** | 최종 answer + citations 응답 조합 (F-RAG-05) | 완료 | + +RAG 블록은 명세상 자기만의 엔드포인트가 없다(6장) — 검색 블록의 `POST /search`가 검색 완료 후 내부적으로 RAG를 호출해 하나의 응답으로 조합하는 구조다. 그래서 이번 이슈는 새 API를 추가하는 게 아니라 **기존 검색 엔드포인트를 확장**하는 작업이다. + +이 이슈에서 Issue 4가 미뤄뒀던 것도 함께 처리한다: `response_citations.search_result_id`가 지금까지 `null`이었는데, 이번에 실제 값으로 채운다. + +--- + +## 전체 흐름 + +```text +POST /search + │ + ▼ +SearchController.search() + │ + ├─ 1. searchFacade.search(userId, request) ← 자체 트랜잭션, 여기서 커밋까지 끝남 + │ 내부: 임베딩 → 권한 pre-filter → 벡터 검색 → live check → search_results 저장 + │ 반환: SearchOutcome(response, candidates, savedResults) + │ + ├─ 2. ragFacade.generate(queryId, queryText, candidates, savedResults) ← 별도 트랜잭션 + │ │ + │ ├─ candidates 비어있음 → createNoContext() (LLM 호출 생략) + │ │ + │ └─ candidates 있음 + │ ├─ PromptBuilder.build() ← Issue 1 + │ ├─ OllamaClient.generate() ← Issue 2 + │ ├─ RagResponseCommandService.createSuccess/createFailed() ← Issue 3 + │ └─ ResponseCitationCommandService.saveAll() ← Issue 4 (search_result_id 채움) + │ 반환: RagAnswer(answerText, citations) + │ + └─ 3. outcome.response().withAnswer(ragAnswer.answerText(), ragAnswer.citations()) + → 최종 SearchResponse(queryId, results, answer, citations) 반환 +``` + +`SearchFacade`(1번)와 `RagFacade`(2번)는 `SearchController`가 순차 호출하는 별개의 트랜잭션이다 — 검색 DB 작업과, Ollama HTTP 호출(최대 30초)을 포함한 RAG DB 작업이 하나의 커넥션을 오래 물고 있지 않도록 분리했다. + +--- + +## 변경 파일 — 검색 블록 + +### 1. `domain/search/service/command/SearchResultCommandService.java` + +```java +public List saveAll(SearchQuery searchQuery, List candidates) { + List results = new ArrayList<>(); + for (int i = 0; i < candidates.size(); i++) { ... } + return searchResultRepository.saveAll(results); +} +``` +반환 타입만 `void` → `List`로 바꿨다. 로직은 그대로다 — `JpaRepository.saveAll()`이 이미 저장된 엔티티 리스트를 반환하므로 그걸 그대로 돌려주기만 하면 된다. **이걸 바꾼 이유**: 저장된 `SearchResult`의 실제 `id`가 있어야 `ResponseCitationCommandService`가 `search_result_id`를 채울 수 있는데, 지금까지는 저장하고 그 결과를 아무도 못 받았다 (Issue 4 설계 문서에서 이미 예고했던 변경). + +### 2. `domain/search/dto/SearchOutcome.java` (신규) + +```java +public record SearchOutcome( + SearchResponse response, + List candidates, + List savedResults +) { +} +``` +`SearchFacade`가 `SearchController`에게 넘겨주는 **내부 전달용** 객체다(API로 노출되지 않음). `SearchResponse`(API 응답 DTO)는 화면 표시용 `SearchResultItem`만 담고 있어 chunk/document id, 저장된 `SearchResult`의 id가 없다. `RagFacade.generate()` 호출에 이 정보들이 필요해서, `SearchResponse`를 감싸는 wrapper로 따로 만들었다. + +### 3. `domain/search/service/SearchFacade.java` + +반환 타입이 `SearchResponse` → `SearchOutcome`으로 바뀌었다. 로직 흐름 자체(임베딩 → 권한 필터 → 벡터 검색 → live check → 저장)는 전혀 안 바뀌었고, 마지막에 반환하는 지점 두 군데만 `SearchOutcome`으로 감싼다. + +```java +// 접근 가능한 문서 0건일 때 +return new SearchOutcome(SearchResponse.empty(searchQuery.getId()), List.of(), List.of()); + +// 정상 흐름 끝 +List savedResults = searchResultCommandService.saveAll(searchQuery, verified); +... +return new SearchOutcome(SearchResponse.of(searchQuery.getId(), verified), verified, savedResults); +``` +두 경우 다 `candidates`가 빈 리스트(`List.of()`)로 귀결되는 걸 주목할 것 — 이 덕분에 `RagFacade`는 "접근 가능 문서 0건"과 "live check로 전부 탈락"을 구분할 필요 없이 `candidates.isEmpty()` 하나로만 NO_CONTEXT를 판단할 수 있다. + +### 4. `domain/search/dto/response/SearchResponse.java` + +```java +public record SearchResponse( + Long queryId, + List results, + String answer, + List citations +) { + public static SearchResponse of(Long queryId, List candidates) { + ... + return new SearchResponse(queryId, List.copyOf(items), null, List.of()); + } + + public static SearchResponse empty(Long queryId) { + return new SearchResponse(queryId, List.of(), null, List.of()); + } + + public SearchResponse withAnswer(String answer, List citations) { + return new SearchResponse(queryId, results, answer, citations); + } +} +``` +`answer`/`citations` 필드를 추가했다. 기존 `results` 필드는 그대로 유지했다 — 검색 후보 전체(citations는 그중 실제로 인용된 것만)를 프론트가 계속 볼 수 있어야 한다는 판단 때문이다(하위 호환). + +레코드는 불변이라 "일단 `queryId`/`results`만 채워서 만들고, 나중에 `answer`/`citations`를 마저 채우는" 2단계 조립이 필요했다. `of()`/`empty()`는 `answer=null, citations=[]`인 상태로 만들고, `withAnswer()`가 그 두 필드만 갈아끼운 새 레코드를 반환한다. `SearchController`가 `outcome.response().withAnswer(...)`로 최종 병합에 쓴다. + +### 5. `domain/search/dto/response/CitationResponse.java` (신규) + +```java +public record CitationResponse( + String label, + Long documentId, + String documentTitle, + Long chunkId, + Integer pageNo, + String quotedText +) { + public static CitationResponse of(int order, VectorSearchCandidate candidate) { + return new CitationResponse( + "[" + order + "]", candidate.documentId(), candidate.documentTitle(), + candidate.chunkId(), candidate.pageNo(), candidate.chunkText() + ); + } +} +``` +API 응답에 노출되는 출처 1건의 모양. 명세 6장의 최종 응답 예시(`{ "label": "[1]", "documentId": 10, ... }`)와 필드를 그대로 맞췄다. `RagFacade`가 갖고 있는 `List`(검색 단계에서 이미 documentId/documentTitle/pageNo/chunkText를 다 갖고 있음)로부터 직접 만들기 때문에 DB 재조회가 없다. + +**왜 이 파일을 `domain/search/dto/response/`(RAG 도메인이 아니라 검색 도메인)에 뒀나 — 순환 참조 방지**: `RagFacade`(rag 도메인)는 이미 `domain.search.dto.VectorSearchCandidate`를 참조하고 있어 "RAG → Search" 의존 방향이 이미 성립해 있다(`java-style.md`: 도메인 간 의존은 단방향, 순환 참조 금지). `SearchResponse`(search 도메인)가 `citations` 필드를 가지려면 그 원소 타입이 필요한데, 이걸 `domain/rag/dto/response/`에 두면 "Search → RAG" 역방향 의존이 추가로 생겨 순환 참조가 된다. 그래서 `CitationResponse`를 search 도메인에 두고, rag 도메인의 `RagAnswer`가 이걸 가져다 쓰는 방향(RAG → Search)으로만 의존이 흐르게 했다. + +### 6. `domain/search/controller/SearchController.java` + +```java +private final SearchFacade searchFacade; +private final RagFacade ragFacade; + +@PostMapping +public ResponseEntity> search( + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestBody @Valid SearchRequest request +) { + SearchOutcome outcome = searchFacade.search(userId, request); + RagAnswer ragAnswer = ragFacade.generate( + outcome.response().queryId(), request.queryText(), outcome.candidates(), outcome.savedResults() + ); + return ResponseUtils.ok(outcome.response().withAnswer(ragAnswer.answerText(), ragAnswer.citations())); +} +``` +`RagFacade`를 주입받아 검색 완료 후 순차 호출한다. Controller 자체는 `@Transactional`이 아니므로(원래도 아니었음), `searchFacade.search()`가 완전히 끝나고(커밋됨) 나서야 `ragFacade.generate()`가 시작된다 — 이 구조 자체가 두 트랜잭션을 물리적으로 분리한다. + +--- + +## 변경 파일 — RAG 블록 + +### 7. `domain/rag/service/command/RagResponseCommandService.java` — `createNoContext()` 추가 + +```java +private static final String NO_CONTEXT_ANSWER_TEXT = "관련 문서를 찾지 못했습니다."; + +public RagResponse createNoContext(SearchQuery query) { + RagResponse ragResponse = RagResponse.builder() + .query(query) + .answerText(NO_CONTEXT_ANSWER_TEXT) + .status(ResultStatus.SUCCESS) + .build(); + return ragResponseRepository.save(ragResponse); +} +``` +검색 결과가 0건이라 LLM을 아예 호출하지 않은 경우를 위한 메서드. `createFailed()`가 이미 쓰고 있는 "고정 문구 상수" 패턴을 그대로 대칭되게 적용했다(`FAILED_ANSWER_TEXT` 옆에 `NO_CONTEXT_ANSWER_TEXT`). `llmProvider`/`llmModelName`/`promptText`는 채우지 않는다 — 실제로 아무것도 호출하지 않았으니 채울 값 자체가 없다. `status`는 `SUCCESS`다 — 이건 실패가 아니라 "검색이 잘 됐는데 결과가 없었다"는 정상적인 결과 케이스이기 때문이다(검색 블록이 접근 가능 문서 0건일 때도 에러가 아니라 빈 배열 200으로 응답하는 것과 동일한 철학). + +### 8. `domain/rag/service/command/ResponseCitationCommandService.java` — `search_result_id` 채우기 + +```java +public void saveAll(RagResponse response, List candidates, List searchResults) { + for (int i = 0; i < candidates.size(); i++) { + ... + .searchResult(searchResults.get(i)) + ... + } +} +``` +`List searchResults` 파라미터가 추가됐다. `candidates`와 `searchResults`는 `SearchResultCommandService.saveAll()`이 **동일한 리스트를 동일한 순서로** 순회하며 만든 것이므로, 인덱스로 1:1 대응한다는 전제로 `searchResults.get(i)`를 그대로 FK에 연결한다(주석으로 이 전제를 명시해뒀다). Issue 4에서 `nullable`이라 비워뒀던 부분이 이번에 채워졌다. + +--- + +## 신규 파일 — RAG 블록 + +### 9. `domain/rag/dto/RagAnswer.java` + +```java +public record RagAnswer(String answerText, List citations) { + public static RagAnswer of(String answerText, List candidates) { + List items = new ArrayList<>(); + for (int i = 0; i < candidates.size(); i++) { + items.add(CitationResponse.of(i + 1, candidates.get(i))); + } + return new RagAnswer(answerText, List.copyOf(items)); + } + public static RagAnswer noContext(String answerText) { + return new RagAnswer(answerText, List.of()); + } +} +``` +`RagFacade.generate()`의 반환 타입. `SearchOutcome`이 검색 쪽 결과를 담는 그릇이라면, 이건 RAG 쪽 결과를 담는 그릇이다. `citations`는 `ResponseCitationCommandService`가 DB에 저장한 것과 별개로, `candidates`로부터 **직접** 다시 만든다(같은 라벨 규칙 `"[" + order + "]"` 재사용) — DB에 저장한 걸 다시 SELECT해서 응답을 조립하지 않고, 이미 메모리에 있는 값으로 응답도 함께 조립하는 것이다. + +### 10. `domain/rag/service/RagFacade.java` — 이번 이슈의 핵심 조율자 + +```java +@Transactional +@Service +@RequiredArgsConstructor +@Slf4j +public class RagFacade { + + private final PromptBuilder promptBuilder; + private final OllamaClient ollamaClient; + private final RagResponseCommandService ragResponseCommandService; + private final ResponseCitationCommandService responseCitationCommandService; + private final EntityManager entityManager; + + public RagAnswer generate( + Long queryId, String queryText, List candidates, List searchResults + ) { + SearchQuery queryRef = entityManager.getReference(SearchQuery.class, queryId); + + // 검색 후보가 없으면(NO_CONTEXT) LLM 호출 없이 고정 응답 저장 + if (candidates.isEmpty()) { + RagResponse ragResponse = ragResponseCommandService.createNoContext(queryRef); + log.info("[RAG] no context queryId={} responseId={}", queryId, ragResponse.getId()); + return RagAnswer.noContext(ragResponse.getAnswerText()); + } + + // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 + String prompt = promptBuilder.build(queryText, candidates); + try { + OllamaGenerateResult result = ollamaClient.generate(prompt); + RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); + responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); + log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); + return RagAnswer.of(result.answerText(), candidates); + } catch (DocGridException e) { + ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage()); + throw e; + } + } +} +``` + +**`entityManager.getReference(SearchQuery.class, queryId)`를 쓰는 이유**: `RagFacade`는 `SearchFacade`와 다른 트랜잭션에서 실행된다. `SearchFacade`가 갖고 있던 진짜 `SearchQuery` 엔티티 객체를 그대로 넘겨받으면 detached 상태 문제가 생길 수 있어서, `queryId`(Long)만 받아 이 트랜잭션 안에서 새로 프록시를 만든다. `SearchResultCommandService`가 `DocumentChunk`/`Embedding` FK에 이미 쓰고 있는 것과 동일한 기법이다. + +**`@Transactional`을 클래스에 붙인 이유**: `createSuccess()`(또는 `createNoContext()`)와 `saveAll()`(citation 저장)이 하나의 원자적 단위로 묶이길 원했다 — 답변은 저장됐는데 출처 저장이 실패해서 어중간하게 남는 상황을 피하기 위함이다. `SearchFacade`와는 별개의 트랜잭션이므로(Context 문단 참고), 검색 DB 작업과 섞이지 않는다. Ollama HTTP 호출이 이 트랜잭션 안에 포함되는 것 자체는 `SearchFacade`가 임베딩 HTTP 호출을 트랜잭션에 포함하는 것과 동일한 기존 트레이드오프를 그대로 따른다(MVP 단계 단순성 우선, 두 설계 문서 모두에 명시된 남은 이슈). + +**실패 시 `createFailed()` 후 예외 재전파**: `catch (DocGridException e)`에서 실패 기록을 남기고 예외를 그대로 다시 던진다. `RagFacade`는 HTTP 상태 코드를 직접 조립하지 않는다 — `GlobalExceptionHandler`가 `RAG_SERVICE_UNAVAILABLE`을 받아 503으로 변환한다. 이때 이미 커밋된 검색 결과(`search_results`)는 별도 트랜잭션(`SearchFacade`)에서 저장된 것이라 영향받지 않고 그대로 남는다. + +--- + +## 로컬 검증 (실제 수행 기록) + +```bash +$ ./gradlew compileTestJava +BUILD SUCCESSFUL + +$ ./gradlew test # 전체 테스트 스위트 +BUILD SUCCESSFUL + +$ ./gradlew build -x test +BUILD SUCCESSFUL +``` + +기존 검색 블록 테스트(`SearchFacadeTest`, `SearchResultCommandServiceTest`)와 RAG 블록 테스트(`RagResponseCommandServiceTest`, `ResponseCitationCommandServiceTest`)를 이번 이슈의 시그니처 변경에 맞춰 함께 수정했고, 신규 `RagFacadeTest`(NO_CONTEXT/정상/실패 3케이스)를 추가했다. 전체 테스트 스위트가 회귀 없이 통과했다. + +실제 문서 업로드/인덱싱 후 `POST /search`를 Swagger로 호출하는 e2e 확인은 별도로 진행 예정이다(이 문서에는 자동화 테스트 결과만 기록). + +--- + +## 에러 케이스 정리 + +| 상황 | 처리 | +|---|---| +| 검색 자체 실패(임베딩 서버 장애, 사용자/컬렉션 없음 등) | 기존 `SearchFacade`의 에러 처리 그대로(변경 없음) — `RagFacade`는 호출되지도 않음 | +| 접근 가능 문서 0건 / live check로 전부 탈락 | `SearchOutcome.candidates()`가 빈 리스트 → `RagFacade`가 `createNoContext()`로 처리, 200 정상 응답 + 고정 answer 문구 | +| Ollama 호출 실패(타임아웃/연결거부) | `createFailed()`로 FAILED 기록 후 예외 재전파 → 503 `RAG_SERVICE_UNAVAILABLE`. 검색 결과(`search_results`)는 이미 별도 트랜잭션에서 커밋되어 그대로 유지됨 | +| 정상 흐름 | 200 + `results`(검색 후보 전체) + `answer`(LLM 답변) + `citations`(실제 인용된 출처) | + +--- + +## 설계 결정 요약 + +**`SearchFacade`/`RagFacade` 트랜잭션 분리**: `SearchController`가 두 Facade를 순차 호출하는 구조 자체로 트랜잭션이 물리적으로 나뉜다. LLM HTTP 호출(최대 30초)이 검색 DB 작업과 같은 커넥션을 오래 물고 있지 않도록 하기 위함. + +**`SearchOutcome`/`RagAnswer` — API로 노출되지 않는 내부 전달용 레코드 2개 도입**: `SearchFacade`→`Controller`, `RagFacade`→`Controller` 각각의 결과를 담는 그릇을 분리해서, 최종 API 응답(`SearchResponse`)과 내부 처리에 필요한 데이터(원본 후보, 저장된 엔티티)를 섞지 않았다. + +**`CitationResponse`를 search 도메인에 배치해 순환 참조 방지**: RAG가 이미 Search에 의존하는 기존 방향을 유지하기 위한 의도적 선택. + +**NO_CONTEXT와 FAILED를 대칭적으로 설계**: 둘 다 `RagResponseCommandService`에 고정 문구 상수 + 전용 생성 메서드로 존재한다. 차이는 `status`(SUCCESS vs FAILED)와 트랜잭션 전파(`createNoContext`는 일반 `REQUIRED`, `createFailed`는 `REQUIRES_NEW`) 정도다. + +**citations 응답은 DB 재조회 없이 메모리의 `candidates`로부터 재구성**: `ResponseCitationCommandService`가 저장한 것과 `RagAnswer.of()`가 만드는 것은 별개의 객체 생성이지만, 소스(`candidates`)와 라벨 규칙(`"[" + order + "]"`)이 동일해 항상 일치한다. + +--- + +## 남은 이슈 / TODO + +### 코드 +- `SearchFacade`(임베딩 HTTP 호출 포함)와 `RagFacade`(Ollama HTTP 호출 포함) 둘 다 "트랜잭션 안에 HTTP 호출 포함" 트레이드오프를 갖고 있다 — 두 곳 다 아직 정식으로 트랜잭션 분리 리팩터링은 하지 않았다(`#56` 설계 문서에도 동일하게 기록됨). +- `SearchQueryCommandService.markFailed()`/`RagResponseCommandService.createFailed()`의 `REQUIRES_NEW` 트랜잭션 경계는 여전히 Mockito 단위 테스트로만 검증되고, Spring 통합 테스트는 없다(`#56`, `#73` 문서에 동일하게 기록된 기존 갭). + +### 다음 단계 +RAG 블록(F-RAG-01~05) 전체 구현이 이걸로 완료된다. 이제 실제 문서를 업로드해 인덱싱까지 마친 뒤 Swagger에서 `POST /search`를 직접 호출해, `results` + `answer` + `citations`가 한 응답에 정상적으로 담기는지 e2e로 확인하는 절차가 남아있다. From a6e8810fdd829bdc9a8649c626a33f8147a98bef Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:28:52 +0900 Subject: [PATCH 12/14] =?UTF-8?q?[Fix]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20search=5Fresult=5F?= =?UTF-8?q?id=20=EC=97=B0=EA=B2=B0=20=EB=B0=A9=EC=8B=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResponseCitationCommandService: SearchFacade 트랜잭션에서 넘어온(detached) SearchResult 엔티티를 그대로 FK에 대입하던 것을, chunk 필드와 동일하게 entityManager.getReference(SearchResult.class, id)로 통일한다. RagResponse: llmModelName은 하드코딩이 아니라 OllamaGenerateResult.model()에서 동적으로 채워지는 값이라, 필드 주석에서 특정 모델명 예시를 제거한다. --- .../opensource/docgrid/domain/rag/entity/RagResponse.java | 4 ++-- .../rag/service/command/ResponseCitationCommandService.java | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java b/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java index db00255..b6610de 100644 --- a/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java +++ b/src/main/java/com/opensource/docgrid/domain/rag/entity/RagResponse.java @@ -57,11 +57,11 @@ public class RagResponse extends BaseEntity { @Column(name = "answer_text", nullable = false, columnDefinition = "TEXT") private String answerText; - // LLM 제공자 이름 (Ollama) + // LLM 제공자 이름 @Column(name = "llm_provider", length = 50) private String llmProvider; - // LLM 모델 이름 (qwen2.5:3b) + // LLM 모델 이름 @Column(name = "llm_model_name", length = 100) private String llmModelName; diff --git a/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java b/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java index a690d23..32cbcac 100644 --- a/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java +++ b/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java @@ -21,6 +21,10 @@ * *

PromptBuilder가 프롬프트에 포함시킨 것과 동일한 candidates 순서를 citation_order/citation_label * 근거로 그대로 재사용한다. search_result_id는 searchResults 인자로 함께 받아 연결한다. + * + *

searchResults는 SearchFacade의 트랜잭션이 이미 끝난(detached) 엔티티라, 그 객체를 그대로 FK에 + * 대입하지 않고 getId()만 꺼내 entityManager.getReference()로 이 트랜잭션의 프록시를 새로 만든다 — + * chunk 필드를 연결할 때와 동일한 방식이다. */ @Transactional @Service @@ -38,7 +42,7 @@ public void saveAll(RagResponse response, List candidates citations.add(ResponseCitation.builder() .response(response) .chunk(entityManager.getReference(DocumentChunk.class, c.chunkId())) - .searchResult(searchResults.get(i)) + .searchResult(entityManager.getReference(SearchResult.class, searchResults.get(i).getId())) .citationOrder(i + 1) .citationLabel("[" + (i + 1) + "]") .quotedText(c.chunkText()) From 638a4ee44aa1ad943d68e960063da40f53343105 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:29:02 +0900 Subject: [PATCH 13/14] =?UTF-8?q?[Test]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20search=5Fresult=5F?= =?UTF-8?q?id=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20savedResults=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResponseCitationCommandServiceTest: getReference(SearchResult.class, id) 방식으로 바뀐 구현에 맞춰 mocking을 수정한다. SearchFacadeTest: saveAll()이 빈 리스트를 반환하도록 stub되어 있어 SearchOutcome.savedResults() 전달 여부를 검증하지 못하던 것을 보강한다. --- .../command/ResponseCitationCommandServiceTest.java | 11 +++++++++-- .../domain/search/service/SearchFacadeTest.java | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java b/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java index 9f90120..e53fbe1 100644 --- a/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java @@ -52,11 +52,18 @@ void saveAll_savesWithOrderAndLabel() { VectorSearchCandidate c2 = candidate(20L, "복지정책 내용", 3, new BigDecimal("0.8")); DocumentChunk chunk1 = mock(DocumentChunk.class); DocumentChunk chunk2 = mock(DocumentChunk.class); + // searchResult1/2는 SearchFacade 트랜잭션에서 이미 저장되어 detached된 엔티티를 흉내낸다 — id만 의미가 있다. SearchResult searchResult1 = mock(SearchResult.class); SearchResult searchResult2 = mock(SearchResult.class); + given(searchResult1.getId()).willReturn(501L); + given(searchResult2.getId()).willReturn(502L); + SearchResult searchResultRef1 = mock(SearchResult.class); + SearchResult searchResultRef2 = mock(SearchResult.class); given(entityManager.getReference(DocumentChunk.class, c1.chunkId())).willReturn(chunk1); given(entityManager.getReference(DocumentChunk.class, c2.chunkId())).willReturn(chunk2); + given(entityManager.getReference(SearchResult.class, 501L)).willReturn(searchResultRef1); + given(entityManager.getReference(SearchResult.class, 502L)).willReturn(searchResultRef2); given(responseCitationRepository.saveAll(any())).willAnswer(i -> i.getArgument(0)); responseCitationCommandService.saveAll(response, List.of(c1, c2), List.of(searchResult1, searchResult2)); @@ -71,12 +78,12 @@ void saveAll_savesWithOrderAndLabel() { assertThat(saved.get(0).getQuotedText()).isEqualTo("인사규정 내용"); assertThat(saved.get(0).getPageNo()).isEqualTo(12); assertThat(saved.get(0).getRelevanceScore()).isEqualByComparingTo(new BigDecimal("0.9")); - assertThat(saved.get(0).getSearchResult()).isSameAs(searchResult1); + assertThat(saved.get(0).getSearchResult()).isSameAs(searchResultRef1); assertThat(saved.get(0).getChunk()).isSameAs(chunk1); assertThat(saved.get(1).getCitationOrder()).isEqualTo(2); assertThat(saved.get(1).getCitationLabel()).isEqualTo("[2]"); assertThat(saved.get(1).getChunk()).isSameAs(chunk2); - assertThat(saved.get(1).getSearchResult()).isSameAs(searchResult2); + assertThat(saved.get(1).getSearchResult()).isSameAs(searchResultRef2); } @Test diff --git a/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java b/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java index d638cb2..754a3e9 100644 --- a/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java +++ b/src/test/java/com/opensource/docgrid/domain/search/service/SearchFacadeTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -32,6 +33,7 @@ import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; import com.opensource.docgrid.domain.search.dto.request.SearchRequest; import com.opensource.docgrid.domain.search.entity.SearchQuery; +import com.opensource.docgrid.domain.search.entity.SearchResult; import com.opensource.docgrid.domain.search.fixture.SearchQueryFixture; import com.opensource.docgrid.domain.search.service.command.SearchQueryCommandService; import com.opensource.docgrid.domain.search.service.command.SearchResultCommandService; @@ -73,13 +75,15 @@ void search_normalFlow_returnsResults() { given(accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null)).willReturn(List.of(3L)); given(vectorSearchQueryService.search(any(), any(), any(), anyInt())).willReturn(List.of(candidate)); given(permissionQueryService.canReadDocument(USER_ID, 3L)).willReturn(true); - given(searchResultCommandService.saveAll(any(), any())).willReturn(List.of()); + SearchResult savedResult = mock(SearchResult.class); + given(searchResultCommandService.saveAll(any(), any())).willReturn(List.of(savedResult)); SearchOutcome outcome = searchFacade.search(USER_ID, REQUEST); assertThat(outcome.response().results()).hasSize(1); assertThat(outcome.response().results().get(0).rank()).isEqualTo(1); assertThat(outcome.candidates()).hasSize(1); + assertThat(outcome.savedResults()).containsExactly(savedResult); then(searchResultCommandService).should(times(1)).saveAll(any(), any()); then(searchQueryCommandService).should(times(1)).markSuccess(any(), anyInt()); } From 2414f8fcf09c9dfc53426891bc6ceee08ca77b33 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 29 Jul 2026 15:29:09 +0900 Subject: [PATCH 14/14] =?UTF-8?q?[Docs]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=EB=82=B4=EC=97=AD=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 코멘트 5건의 처리 내역과, entityManager.getReference()가 성능 목적/트랜잭션 안전성 목적 두 가지로 쓰이는 이유를 정리한다. --- ...kangcheolung-#75-rag-facade-integration.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/design/kangcheolung-#75-rag-facade-integration.md b/docs/design/kangcheolung-#75-rag-facade-integration.md index ffa0f5a..6c4d921 100644 --- a/docs/design/kangcheolung-#75-rag-facade-integration.md +++ b/docs/design/kangcheolung-#75-rag-facade-integration.md @@ -299,6 +299,39 @@ BUILD SUCCESSFUL --- +## 코드리뷰 반영 (CodeRabbit) + +PR에 자동 코드리뷰 코멘트 5건이 달렸고, 각각 다음과 같이 처리했다. + +| # | 코멘트 요지 | 처리 | 근거 | +|---|---|---|---| +| 1 | `RagResponse.llmProvider`/`llmModelName` 필드 주석에 `(Ollama)`, `(qwen2.5:3b)`처럼 특정 값을 박아뒀다 | **반영함** | `llmModelName`은 하드코딩이 아니라 `OllamaGenerateResult.model()`에서 매 호출마다 동적으로 채워지는 값이다(모델을 3b→7b로 바꿔도 코드 수정 없이 대응하기 위한 설계, `#67` 문서 참고). 주석에 특정 모델명을 박아두면 그 설계 의도와 모순되고, NO_CONTEXT 응답에서는 두 필드 다 비어있기도 해서 필드 역할만 남기는 쪽으로 단순화했다 | +| 2 | `RagFacade` Javadoc의 `(F-RAG-05)` 표기를 "내부 PR 순번 라벨"이라며 제거 요청 | **반영 안 함** | 이건 PR 순번이 아니라 RAG 명세서의 기능 코드다. `SearchFacade`(F-SEARCH-05/06/07), `RagResponseCommandService`(F-RAG-03), `ResponseCitationCommandService`(F-RAG-04) 등 이 코드베이스 전체가 일관되게 이 표기를 쓰고 있어서, 여기만 빼면 형제 클래스들과 일관성이 깨진다 | +| 3 | 신규 `CitationResponse` record에 class-level Javadoc이 없다 | **반영 안 함** | 구조가 가장 비슷한 `SearchResultItem`(rank 기반 응답 DTO + `of()` 팩토리)도 class-level 주석이 없어, 기존 관례와의 일관성을 우선했다 | +| 4 | `ResponseCitationCommandService.saveAll()`이 `SearchFacade`의(이미 detached된) `SearchResult` 엔티티를 그대로 `.searchResult(...)`에 대입하고 있어 위험하다 | **반영함** | 실제로 안전하지 않은 패턴이었다. 아래 별도 문단에서 상세 설명 | +| 5 | `SearchFacadeTest`의 정상 흐름 테스트가 `saveAll()`을 빈 리스트로 stub해둬서, `SearchOutcome.savedResults()` 전달 여부를 실질적으로 검증하지 못하고 있다 | **반영함** | `saveAll()`이 mock `SearchResult` 하나를 반환하도록 바꾸고, `outcome.savedResults()`가 그 값을 그대로 담고 있는지 검증을 추가했다 | + +### 4번 상세 — `entityManager.getReference()`를 쓰는 두 가지 서로 다른 이유 + +이 코드베이스에는 `entityManager.getReference(Class, id)` 패턴이 여러 곳에 나오는데, 사실 이유가 두 가지로 갈린다. + +| 위치 | 이유 | +|---|---| +| `SearchResultCommandService.saveAll()`의 `chunk`/`embedding`, `ResponseCitationCommandService.saveAll()`의 `chunk` (기존부터 있던 코드) | **성능** — 존재가 이미 확실한 엔티티(검색으로 찾아온 chunk 등)의 FK만 연결하면 되는데, `findById()`를 쓰면 불필요한 SELECT가 추가로 나간다. `getReference()`는 실제 쿼리 없이 ID값만 가진 프록시를 만들어 FK 컬럼에 연결한다 | +| `RagFacade.generate()`의 `SearchQuery`, `ResponseCitationCommandService.saveAll()`의 `searchResult` (이번에 수정) | **안전성** — `SearchFacade`(다른 트랜잭션)에서 넘어온 엔티티는 이미 detached 상태다. 그 객체를 새 엔티티의 FK로 그대로 재사용하는 대신, `id`만 꺼내서(`getId()`) 지금 이 트랜잭션 안에서 `getReference()`로 새 프록시를 만든다 | + +`searchResult` 필드는 원래(수정 전) `searchResults.get(i)`를 그대로 대입하고 있었는데, `cascade` 설정이 없어서 당장 예외가 나지는 않지만 트랜잭션 경계를 넘어온 엔티티를 그대로 재사용하는 건 이 코드베이스의 다른 모든 FK 연결 지점(`chunk`, `embedding`, `SearchQuery`)과 방식이 달라 일관성이 깨지고, 더 안전한 방법이 이미 옆 줄(`chunk`)에 있는데 안 쓴 셈이었다. `entityManager.getReference(SearchResult.class, searchResults.get(i).getId())`로 바꿔서 나머지 FK 연결과 동일한 방식으로 통일했다. + +```java +// 수정 전 — detached 엔티티를 그대로 FK에 대입 +.searchResult(searchResults.get(i)) + +// 수정 후 — id만 꺼내 이번 트랜잭션의 프록시로 새로 참조 +.searchResult(entityManager.getReference(SearchResult.class, searchResults.get(i).getId())) +``` + +--- + ## 설계 결정 요약 **`SearchFacade`/`RagFacade` 트랜잭션 분리**: `SearchController`가 두 Facade를 순차 호출하는 구조 자체로 트랜잭션이 물리적으로 나뉜다. LLM HTTP 호출(최대 30초)이 검색 DB 작업과 같은 커넥션을 오래 물고 있지 않도록 하기 위함.