From 98bfcdba15a97b173c19bd77ada93fbc49512035 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 28 Jul 2026 18:55:28 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat]=20ResponseCitation=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=ED=95=84=EB=93=9C=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit citationOrder/citationLabel을 분리한 이유, quotedText/pageNo/relevanceScore가 각각 어떤 의미인지 필드별로 명시한다. --- .../docgrid/domain/rag/entity/ResponseCitation.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/opensource/docgrid/domain/rag/entity/ResponseCitation.java b/src/main/java/com/opensource/docgrid/domain/rag/entity/ResponseCitation.java index 8606ccb..b096e6e 100644 --- a/src/main/java/com/opensource/docgrid/domain/rag/entity/ResponseCitation.java +++ b/src/main/java/com/opensource/docgrid/domain/rag/entity/ResponseCitation.java @@ -70,19 +70,23 @@ public class ResponseCitation extends BaseEntity { @JoinColumn(name = "search_result_id") private SearchResult searchResult; + // 노출 순서(정렬용 숫자). 1, 2, 3... @Column(name = "citation_order", nullable = false) private int citationOrder; - // 사용자에게 노출되는 표시용 라벨. 예: "[1]", "[2]" + // 사용자에게 노출되는 표시용 라벨(문자열). 예: "[1]", "[2]" — citationOrder와 별도로 둬서 표기 스타일만 바뀌어도(예: "(주1)") 정렬 로직에 영향 없게 한다 @Column(name = "citation_label", length = 20) private String citationLabel; + // 실제로 인용된 원문 텍스트. 원본 chunk가 나중에 수정/삭제돼도 답변 당시 근거는 그대로 남긴다 @Column(name = "quoted_text", columnDefinition = "TEXT") private String quotedText; + // 원본 문서에서 몇 페이지였는지. 페이지 개념이 없는 포맷은 null @Column(name = "page_no") private Integer pageNo; + // 검색 시점의 관련도 점수(코사인 유사도). "왜 이 chunk가 뽑혔는지"를 정량적으로 같이 남긴다 @Column(name = "relevance_score") private BigDecimal relevanceScore; From 08fe1dbc680bbe4aa24474f2b202f041ae37dabe Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 28 Jul 2026 18:55:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Feat]=20ResponseCitationRepository=20+=20R?= =?UTF-8?q?esponseCitationCommandService=20=EA=B5=AC=ED=98=84=20(F-RAG-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PromptBuilder가 라벨을 매긴 것과 동일한 candidates 순서를 citation_order/ citation_label 근거로 재사용해 response_citations에 저장한다. SearchResultCommandService.saveAll()과 동일하게 entityManager.getReference()로 불필요한 SELECT 없이 chunk FK를 연결한다. search_result_id는 이번 이슈에서 채우지 않는다(null) — SearchResultCommandService.saveAll()이 저장된 SearchResult를 반환하지 않아 이 시점에는 알 수 없고, Issue 5에서 채운다. --- .../ResponseCitationRepository.java | 8 +++ .../ResponseCitationCommandService.java | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java create mode 100644 src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java diff --git a/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java b/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java new file mode 100644 index 0000000..a145744 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/rag/repository/ResponseCitationRepository.java @@ -0,0 +1,8 @@ +package com.opensource.docgrid.domain.rag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.rag.entity.ResponseCitation; + +public interface ResponseCitationRepository extends JpaRepository { +} 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 new file mode 100644 index 0000000..5a784b1 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandService.java @@ -0,0 +1,49 @@ +package com.opensource.docgrid.domain.rag.service.command; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.document.entity.DocumentChunk; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +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 jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +/** + * 응답 출처(citation) 저장 서비스 (F-RAG-04). + * + *

PromptBuilder가 프롬프트에 포함시킨 것과 동일한 candidates 순서를 citation_order/citation_label + * 근거로 그대로 재사용한다. search_result_id는 이번 이슈에서 채우지 않는다(null) — SearchResultCommandService.saveAll()이 + * 저장된 SearchResult를 반환하지 않아 이 시점에는 알 수 없고, Issue 5에서 채운다. + */ +@Transactional +@Service +@RequiredArgsConstructor +public class ResponseCitationCommandService { + + private final ResponseCitationRepository responseCitationRepository; + private final EntityManager entityManager; + + public void saveAll(RagResponse response, List candidates) { + 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())) + .citationOrder(i + 1) + .citationLabel("[" + (i + 1) + "]") + .quotedText(c.chunkText()) + .pageNo(c.pageNo()) + .relevanceScore(c.similarityScore()) + .build()); + } + responseCitationRepository.saveAll(citations); + } +} From bf114ef0b8677b4ffcd30a450819ae9b5b8328a1 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 28 Jul 2026 18:55:43 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Test]=20ResponseCitationCommandService=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 정상 케이스(순서/라벨/텍스트/페이지/점수 저장, searchResult null 확인)와 빈 후보 리스트 케이스를 검증한다. --- .../ResponseCitationCommandServiceTest.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java 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 new file mode 100644 index 0000000..1e4eefa --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/rag/service/command/ResponseCitationCommandServiceTest.java @@ -0,0 +1,92 @@ +package com.opensource.docgrid.domain.rag.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.document.entity.DocumentChunk; +import com.opensource.docgrid.domain.rag.entity.RagResponse; +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.enums.ResultStatus; + +import jakarta.persistence.EntityManager; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ResponseCitationCommandService 단위 테스트") +class ResponseCitationCommandServiceTest { + + @InjectMocks + private ResponseCitationCommandService responseCitationCommandService; + + @Mock + private ResponseCitationRepository responseCitationRepository; + + @Mock + private EntityManager entityManager; + + @Test + @DisplayName("후보 목록을 순서대로 citation_order/citation_label과 함께 저장한다") + void saveAll_savesWithOrderAndLabel() { + RagResponse response = RagResponse.builder() + .answerText("연차는 입사 1년 기준 15일 부여됩니다.") + .status(ResultStatus.SUCCESS) + .build(); + VectorSearchCandidate c1 = candidate(10L, "인사규정 내용", 12, new BigDecimal("0.9")); + VectorSearchCandidate c2 = candidate(20L, "복지정책 내용", 3, new BigDecimal("0.8")); + + given(entityManager.getReference(eq(DocumentChunk.class), any())).willReturn(null); + given(responseCitationRepository.saveAll(any())).willAnswer(i -> i.getArgument(0)); + + responseCitationCommandService.saveAll(response, List.of(c1, c2)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + then(responseCitationRepository).should(times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(2); + assertThat(saved.get(0).getCitationOrder()).isEqualTo(1); + assertThat(saved.get(0).getCitationLabel()).isEqualTo("[1]"); + 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(1).getCitationOrder()).isEqualTo(2); + assertThat(saved.get(1).getCitationLabel()).isEqualTo("[2]"); + } + + @Test + @DisplayName("후보가 없으면 saveAll에 빈 목록을 전달한다") + void saveAll_emptyCandidates_savesEmptyList() { + RagResponse response = RagResponse.builder() + .answerText("관련 문서를 찾지 못했습니다.") + .status(ResultStatus.SUCCESS) + .build(); + given(responseCitationRepository.saveAll(any())).willAnswer(i -> i.getArgument(0)); + + responseCitationCommandService.saveAll(response, List.of()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + then(responseCitationRepository).should(times(1)).saveAll(captor.capture()); + assertThat(captor.getValue()).isEmpty(); + } + + private VectorSearchCandidate candidate(Long chunkId, String chunkText, Integer pageNo, BigDecimal score) { + return new VectorSearchCandidate(1L, chunkId, 100L, chunkText, pageNo, "제목", score); + } +} From 3a12167e948e0f00d0980ace71c9c36e383ee292 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 28 Jul 2026 18:55:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Docs]=20response=5Fcitations=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #73 --- ...kangcheolung-#73-response-citation-save.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/design/kangcheolung-#73-response-citation-save.md diff --git a/docs/design/kangcheolung-#73-response-citation-save.md b/docs/design/kangcheolung-#73-response-citation-save.md new file mode 100644 index 0000000..962bb7a --- /dev/null +++ b/docs/design/kangcheolung-#73-response-citation-save.md @@ -0,0 +1,174 @@ +# #73 response_citations 저장 — ResponseCitationCommandService 구현 (F-RAG-04) + +closes #73 + +--- + +## 배경 + +Issue 3(#71)에서 답변 문장(`rag_responses`) 저장까지 만들었다. 하지만 "이 답변이 어떤 chunk를 근거로 썼는지"는 아직 어디에도 기록되지 않는다. Issue 4는 그 근거를 `response_citations`에 구조화해서 저장한다 — 이 테이블이 있어야 "출처 기반 검색"이라고 말할 수 있다는 게 RAG 명세의 핵심 전제다. + +마이그레이션(`V24__create_response_citations.sql`)과 엔티티(`ResponseCitation.java`)는 이슈 착수 전부터 이미 존재했다. 이번 이슈는 `ResponseCitationRepository`와 저장 로직(Command 서비스)만 새로 만든다. + +| 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) | 예정 | + +**이번 이슈에서 하지 않는 것**: `SearchFacade`/`SearchController`와의 연결(Issue 5), `search_result_id` 채우기(Issue 5 — 아래 "의도적으로 미룬 부분" 참고). `ResponseCitationCommandService`도 Issue 1~3의 다른 컴포넌트들처럼 아직 어디에도 연결되지 않은 독립 컴포넌트이며 단위 테스트로만 검증한다. + +--- + +## 전체 흐름 + +```text +(아직 어디에도 연결되지 않음 — Issue 5에서 연결 예정) +PromptBuilder.build(queryText, candidates) ← Issue 1이 이미 [1],[2]... 라벨을 매긴 그 candidates + │ + ▼ +OllamaClient.generate(prompt) → RagResponseCommandService.createSuccess(...) ← Issue 2, 3 + │ │ + │ ▼ + │ RagResponse (저장됨, id 확보) + │ + └──────────────┬───────────────┘ + ▼ + ResponseCitationCommandService.saveAll(ragResponse, candidates) ← 이 문서 + │ + ▼ + response_citations INSERT × N (citation_order=1,2,3... citation_label="[1]","[2]"...) + │ + └─ search_result_id는 이번 이슈에서 null (Issue 5에서 채움) +``` + +`candidates`(`List`)는 Issue 1의 `PromptBuilder`가 프롬프트 조립에 쓴 것과 **동일한 리스트, 동일한 순서**를 그대로 재사용한다 — 별도로 라벨을 다시 계산하지 않는다. + +--- + +## 신규 파일 + +### 1. `domain/rag/repository/ResponseCitationRepository.java` + +```java +public interface ResponseCitationRepository extends JpaRepository { +} +``` +`RagResponseRepository`, `SearchQueryRepository`와 동일하게 커스텀 쿼리가 필요 없어 빈 인터페이스로 둔다. + +### 2. `domain/rag/service/command/ResponseCitationCommandService.java` + +```java +@Transactional +@Service +@RequiredArgsConstructor +public class ResponseCitationCommandService { + + private final ResponseCitationRepository responseCitationRepository; + private final EntityManager entityManager; + + public void saveAll(RagResponse response, List candidates) { + 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())) + .citationOrder(i + 1) + .citationLabel("[" + (i + 1) + "]") + .quotedText(c.chunkText()) + .pageNo(c.pageNo()) + .relevanceScore(c.similarityScore()) + .build()); + } + responseCitationRepository.saveAll(citations); + } +} +``` + +**한 줄 요약**: 답변 하나(`RagResponse`)와 그 답변이 근거로 쓴 검색 후보 리스트를 받아, 후보마다 `ResponseCitation` 엔티티를 만들어 일괄 저장한다. + +**왜 `SearchResultCommandService.saveAll()`을 그대로 본떴나**: "후보 리스트를 순회하며 엔티티를 만들고 `saveAll()`한다"는 문제 모양이 검색 블록에서 이미 검증된 것과 완전히 같다. 새로 고안하지 않고 그 구조(반복문 + `entityManager.getReference()` + `saveAll()`)를 그대로 재사용했다. + +**`entityManager.getReference(DocumentChunk.class, c.chunkId())`를 쓰는 이유**: `chunk_id` FK를 연결할 때, 이 chunk가 실제로 존재하는지 확인하는 SELECT가 필요 없다(이미 검색에서 찾아온 chunk라 존재가 보장됨). `getReference()`는 실제 쿼리 없이 ID값만 가진 프록시를 만들어 FK 컬럼에 연결한다 — `findById()`를 썼다면 후보 N개마다 SELECT N번이 나갔을 것을 전부 생략한다. `SearchResultCommandService.saveAll()`이 `Embedding`/`DocumentChunk`에 대해 이미 쓰고 있는 것과 동일한 최적화다. + +**`citationOrder`/`citationLabel`을 후보 리스트 인덱스로 정한 이유**: `candidates`는 이미 유사도 정렬 + live check를 거친 최종 순서이고, Issue 1의 `PromptBuilder`가 프롬프트에 `[1]`, `[2]`... 라벨을 매길 때 쓴 것과 **완전히 동일한 순서**다. 이 순서를 그대로 재사용하면 "프롬프트에서 [2]번으로 인용된 chunk"와 "DB에 citation_order=2로 저장된 chunk"가 항상 일치한다. 별도의 라벨 매핑 구조체 없이 리스트 인덱스(`i+1`)를 바로 쓴다. + +**`citationLabel` 포맷을 `"[" + (i+1) + "]"`로 고정한 이유**: `PromptBuilder`의 라벨 포맷(`"[%d]"`)과 그대로 맞췄다. 두 곳에서 서로 다른 포맷을 쓰면 "프롬프트에 보이는 라벨"과 "DB에 저장된 라벨"이 시각적으로 어긋날 수 있어서, 문자열을 그대로 일치시켰다. + +**`quotedText`에 chunk 원문 전체를 저장하는 이유**: 명세가 "핵심 문장(또는 전체)"로 열어뒀는데, 핵심 문장만 뽑으려면 문장 분리·중요도 판단 같은 별도 로직이 필요하다. 지금 단계에서 그 복잡도를 추가할 이유가 없어, 검색 블록의 `SearchResult.matchedText`가 이미 하는 것처럼 전체 텍스트를 그대로 저장한다. 부가적으로, 원본 `document_chunks`가 나중에 수정되더라도 "답변 당시 실제로 인용했던 문구"가 스냅샷처럼 남는다는 이점도 있다. + +**`relevanceScore`에 `candidate.similarityScore()`를 그대로 넣는 이유**: 둘 다 이미 `BigDecimal`이라 변환 없이 대입 가능하고, "이 chunk가 왜 뽑혔는지"를 정량적으로 같이 남겨 나중에 답변 품질을 분석할 때 활용할 수 있다. + +### 3. `src/test/java/.../rag/service/command/ResponseCitationCommandServiceTest.java` + +`SearchResultCommandServiceTest`와 동일한 Mockito 패턴(`EntityManager.getReference` mock + `ArgumentCaptor>`). + +| 테스트 | 검증 내용 | +|---|---| +| `saveAll_savesWithOrderAndLabel` | 후보 2개 → `citationOrder` 1,2 / `citationLabel` "[1]","[2]" / `quotedText`/`pageNo`/`relevanceScore`가 후보 값 그대로 담기는지, `searchResult`가 `null`인지 | +| `saveAll_emptyCandidates_savesEmptyList` | 빈 리스트 입력 시 `saveAll`에 빈 리스트가 전달되는지 | + +--- + +## 의도적으로 미룬 부분 — `search_result_id` + +`ResponseCitation.searchResult`(`search_result_id` FK, nullable)는 "이 chunk가 검색 단계에서 정확히 몇 번째 `search_results` row였는지"를 연결하는 컬럼이다. 이번 이슈에서는 이 값을 **채우지 않는다** — `ResponseCitation.builder()`에서 `.searchResult(...)` 호출 자체를 생략한다. + +**왜 못 채우나**: `SearchResultCommandService.saveAll()`(검색 블록, 기존 코드)이 지금 `void`를 반환한다. `SearchResult` 엔티티들을 DB에 저장은 하지만, 저장된 엔티티(및 그 `id`)를 호출한 쪽에 돌려주지 않는다. `ResponseCitationCommandService`는 `List`(순수 DTO, DB PK 아님)만 갖고 있어서, 실제 `search_results.id` 값을 이 시점에는 알 방법이 없다. + +**왜 지금 억지로 채우지 않았나**: `search_result_id` 컬럼이 `nullable`이라 스키마상 비워둬도 문제가 없다. 이 값을 채우려면 검색 블록의 기존 코드(`SearchResultCommandService`)까지 함께 고쳐야 하는데, 그건 "검색 결과와 RAG 응답을 하나로 묶는" 작업이라 성격상 Issue 5(`RagFacade`가 두 결과를 모두 쥐는 시점)에서 하는 게 범위가 깔끔하다고 판단했다. + +**Issue 5에서 할 일 (예고)**: `SearchResultCommandService.saveAll()`을 `List`를 반환하도록 바꾸고, `RagFacade`가 그 결과와 `candidates`를 함께 갖고 있는 시점에 `ResponseCitationCommandService.saveAll()`(또는 그 오버로드)에 `search_result_id` 매핑까지 전달하도록 수정할 예정이다. 즉 이번 이슈에서 만든 `ResponseCitationCommandService`는 Issue 5에서 다시 한 번 손볼 것이 확정적으로 예정되어 있다 — Issue 3에서 Issue 2의 `OllamaClient` 관련 파일들을 다시 수정했던 것과 같은 패턴이다. + +--- + +## 로컬 검증 (실제 수행 기록) + +```bash +$ ./gradlew test --tests "*ResponseCitationCommandServiceTest*" +BUILD SUCCESSFUL + +$ ./gradlew build -x test +BUILD SUCCESSFUL + +$ ./gradlew test # 전체 테스트 스위트 — 회귀 없는지 확인 +BUILD SUCCESSFUL +``` + +--- + +## 에러 케이스 정리 + +`ResponseCitationCommandService`는 예외를 던지지 않는다 — 저장 로직 자체가 실패하는 경우(DB 장애 등)는 이 이슈의 관심사가 아니다. + +| 상황 | 처리 | +|---|---| +| `candidates`가 빈 리스트 | 빈 리스트로 `saveAll()` 호출 — 예외 없이 정상 종료 (실제로 이 메서드를 호출할지 말지는 Issue 5의 `RagFacade`가 판단) | +| `candidates`에 chunk가 여러 개 | 순서대로 `citation_order` 1부터 부여, 각각 개별 `ResponseCitation` row로 저장 | + +--- + +## 설계 결정 요약 + +**`SearchResultCommandService.saveAll()`을 그대로 본뜬 구현**: 같은 유형의 문제(후보 리스트 → 엔티티 배치 저장)에 이미 검증된 패턴이 있어서 새로 고안하지 않았다. + +**클래스명을 이슈 설명의 "CitationService"에서 `ResponseCitationCommandService`로 변경**: 기존 Command 서비스 네이밍 컨벤션(`SearchQueryCommandService`, `SearchResultCommandService`, `RagResponseCommandService`)과 일관되게 맞췄다. 패키지 위치도 `domain/rag/service/command/`로 `RagResponseCommandService`와 나란히 뒀다. + +**citation 순서/라벨을 `PromptBuilder`와 동일하게 재사용**: 프롬프트에 인용된 번호와 저장되는 citation 번호가 항상 일치하도록, 별도 재계산 없이 같은 리스트/같은 순서를 공유한다. + +**`search_result_id`를 의도적으로 비워둠**: 검색 블록 코드(`SearchResultCommandService`) 수정이 필요한 작업이라, 검색 결과와 RAG 응답을 실제로 통합하는 Issue 5로 범위를 미뤘다. `nullable` 컬럼이라 스키마 제약을 어기지 않는다. + +--- + +## 남은 이슈 / TODO + +### 코드 +- `ResponseCitationCommandService`는 아직 어디에서도 호출되지 않는 독립 컴포넌트 — Issue 5에서 `RagFacade`가 실제로 연결한다. +- `search_result_id` 채우기 — Issue 5에서 `SearchResultCommandService.saveAll()`을 `List` 반환으로 바꾼 뒤 연결 (위 "의도적으로 미룬 부분" 참고). +- (코드리뷰에서 논의된 항목, Issue 3 PR) `RagResponseCommandService.createFailed()`의 `REQUIRES_NEW` 트랜잭션 경계에 대한 Spring 통합 테스트는 검색 블록의 `SearchQueryCommandService.markFailed()`와 동일하게 아직 없음 — 두 군데를 한 번에 정리하는 별도 이슈로 고려. + +### 다음 단계 +Issue 5 — `RagFacade` 신설. `SearchFacade`와 별도 트랜잭션으로 분리해 `PromptBuilder → OllamaClient → RagResponseCommandService → ResponseCitationCommandService`를 순서대로 호출하도록 묶고, `SearchController`가 `searchFacade.search()` 이후 `ragFacade.generate(...)`를 호출하도록 연결한다. 검색 결과 0건(NO_CONTEXT) 판단도 이 단계에서 들어간다. `SearchResponse`에 `answer`/`citations` 필드를 추가해 `POST /search` 최종 응답을 완성한다. From 9ca9857b0f3ac8553ceb0a16bae3406c2c145c24 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 28 Jul 2026 19:05:45 +0900 Subject: [PATCH 5/5] =?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=20chunk=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EA=B4=80=EA=B3=84=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entityManager.getReference()가 항상 null을 반환하도록 mocking되어 있어 잘못된 chunkId가 전달돼도 테스트가 통과하던 문제를 수정한다. 후보별로 서로 다른 DocumentChunk mock을 반환하도록 하고, 저장된 citation의 chunk가 올바르게 매핑됐는지 검증을 추가한다. --- .../command/ResponseCitationCommandServiceTest.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 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 1e4eefa..4116abe 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 @@ -2,9 +2,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -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.times; import java.math.BigDecimal; @@ -49,8 +49,11 @@ void saveAll_savesWithOrderAndLabel() { .build(); VectorSearchCandidate c1 = candidate(10L, "인사규정 내용", 12, new BigDecimal("0.9")); VectorSearchCandidate c2 = candidate(20L, "복지정책 내용", 3, new BigDecimal("0.8")); + DocumentChunk chunk1 = mock(DocumentChunk.class); + DocumentChunk chunk2 = mock(DocumentChunk.class); - given(entityManager.getReference(eq(DocumentChunk.class), any())).willReturn(null); + 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)); @@ -66,8 +69,10 @@ void saveAll_savesWithOrderAndLabel() { 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).getChunk()).isSameAs(chunk1); assertThat(saved.get(1).getCitationOrder()).isEqualTo(2); assertThat(saved.get(1).getCitationLabel()).isEqualTo("[2]"); + assertThat(saved.get(1).getChunk()).isSameAs(chunk2); } @Test