-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] answer + citations 응답 조합 #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
189a018
5f04860
ee5fb1e
f7f8cb8
b873d65
2b68f9e
636cf28
41850a5
e77c1e3
d5b4450
bd5aabf
a6e8810
638a4ee
2414f8f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CitationResponse> citations) { | ||
|
|
||
| public static RagAnswer of(String answerText, List<VectorSearchCandidate> candidates) { | ||
| List<CitationResponse> 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()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). | ||
| * | ||
| * <pre> | ||
| * 1. candidates가 비어있으면(NO_CONTEXT) LLM 호출 없이 고정 응답 저장 | ||
| * 2. PromptBuilder로 프롬프트 조립 | ||
| * 3. OllamaClient 호출 | ||
| * 4. rag_responses 저장 (성공/실패) | ||
| * 5. 성공 시 response_citations 저장 | ||
| * </pre> | ||
| * | ||
| * <p>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<VectorSearchCandidate> candidates, List<SearchResult> 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; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 응답 아님). | ||
| * | ||
| * <p>candidates/savedResults는 RagFacade.generate() 호출에 필요하다 — SearchResponse(API 응답)는 | ||
| * 표시용 SearchResultItem만 담고 있어 chunk/document id, 저장된 SearchResult의 id가 없다. | ||
| */ | ||
| public record SearchOutcome( | ||
| SearchResponse response, | ||
| List<VectorSearchCandidate> candidates, | ||
| List<SearchResult> savedResults | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+7
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 신규 DTO에 클래스 수준 주석을 추가해 주세요.
제안+/**
+ * RAG 답변에 포함되는 검색 출처 1건을 표현하는 읽기 전용 응답 DTO.
+ *
+ * <p>검색 후보의 문서·청크 메타데이터를 API 응답 형태로 변환한다.
+ */
public record CitationResponse(코딩 가이드라인의 “새로 생성된 class, interface, record에는 역할·책임·경계를 설명하는 class-level comment가 있어야 한다” 규칙에 따른 수정입니다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||||||
| public static CitationResponse of(int order, VectorSearchCandidate candidate) { | ||||||||||||||||||||||||||||||||||||||||||||
| return new CitationResponse( | ||||||||||||||||||||||||||||||||||||||||||||
| "[" + order + "]", | ||||||||||||||||||||||||||||||||||||||||||||
| candidate.documentId(), | ||||||||||||||||||||||||||||||||||||||||||||
| candidate.documentTitle(), | ||||||||||||||||||||||||||||||||||||||||||||
| candidate.chunkId(), | ||||||||||||||||||||||||||||||||||||||||||||
| candidate.pageNo(), | ||||||||||||||||||||||||||||||||||||||||||||
| candidate.chunkText() | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<SearchResult> 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); | ||
|
Comment on lines
+107
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: rg -n -C 6 'class ResponseCitation|searchResult|`@ManyToOne`|cascade' src/main/javaRepository: DocGrid/backend Length of output: 50371 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== candidate files =="
git ls-files | rg 'SearchOutcome|SearchQuery\.java|SearchResultCommandService\.java|ResponseCitationCommandService\.java|RagFacade\.java|ResponseCitation\.java|SearchResult\.java|SearchQueryRepository\.java|SearchResultRepository\.java|ResponseCitationRepository\.java' || true
echo
echo "== SearchOutcome =="
rg -n -C 8 'record SearchOutcome|class SearchOutcome|SearchOutcome' src/main/java || true
echo
echo "== SearchQueryCommandService =="
fd -a 'SearchQueryCommandService\.java$' . | while read -r f; do
echo "--- ${f#$(pwd)/} ---"
wc -l "$f"
sed -n '1,180p' "$f"
done
echo
echo "== ResponseCitationCommandService =="
fd -a 'ResponseCitationCommandService\.java$' . | while read -r f; do
echo "--- ${f#$(pwd)/} ---"
wc -l "$f"
sed -n '1,80p' "$f"
done
echo
echo "== ResponseCitation entity/repository =="
fd -a 'ResponseCitation.*\.java$' . | while read -r f; do
if echo "$f" | rg '(entity|repository|Repository)' >/dev/null; then
echo "--- ${f#$(pwd)/} ---"
wc -l "$f"
sed -n '1,180p' "$f"
fi
done
echo
echo "== search result repositories =="
fd -a 'SearchQueryRepository\.java$|SearchResultRepository\.java$' . | while read -r f; do
echo "--- ${f#$(pwd)/} ---"
wc -l "$f"
sed -n '1,220p' "$f"
done
echo
echo "== SearchQuery entity relevant mapping =="
fd -a 'SearchQuery\.java$' . | while read -r f; do
echo "--- ${f#$(pwd)/} ---"
sed -n '1,180p' "$f"
doneRepository: DocGrid/backend Length of output: 31127 🏁 Script executed: rg -n -C 6 'class ResponseCitation|searchResult|`@ManyToOne`|cascade' src/main/javaRepository: DocGrid/backend Length of output: 50371 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== ResponseCitation searchResult builder/setter excerpts =="
rg -n -C 4 'searchResult|search_result_id' src/main/java/com/opensource/docgrid/domain/rag/entity src/main/java/com/opensource/docgrid/domain/rag/service
echo
echo "== ResponseCitation full builder annotation block =="
sed -n '90,180p' src/main/java/com/opensource/docgrid/domain/rag/entity/ResponseCitation.java
echo
echo "== Search facade transaction outline =="
sed -n '1,28p' src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java
sed -n '35,120p' src/main/java/com/opensource/docgrid/domain/search/service/SearchFacade.java
echo
echo "== RAG facade relevant transaction outline =="
sed -n '1,75p' src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java
echo
echo "== all `@Transactional` declarations around Search/RAG controllers/services =="
rg -n -C 3 '`@Transactional`' src/main/java/com/opensource/docgrid/domain/search src/main/java/com/opensource/docgrid/domain/ragRepository: DocGrid/backend Length of output: 29961 RAG 트랜잭션에서
🤖 Prompt for AI Agents |
||
|
|
||
| } catch (Exception e) { | ||
| searchQueryCommandService.markFailed(searchQuery, e.getMessage()); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
내부 번호 라벨을 설명형 문구로 바꾸세요.
F-RAG-05형식의 내부 순번 라벨이 코드 Javadoc에 노출됩니다. 실제 이슈 번호 또는 기능 설명만 남기세요.수정 예시
As per coding guidelines, “Never expose private numbered PR sequence labels in … code, or comments.”
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines