diff --git a/README.md b/README.md index c006cc20..50d2cc87 100644 --- a/README.md +++ b/README.md @@ -95,22 +95,13 @@ nc -zv 127.0.0.1 55433 로컬 Spring Boot는 `127.0.0.1:55433`으로 접속하지만, 실제 요청은 SSH Tunnel을 통해 EC2의 OpenSQL `127.0.0.1:5432`로 전달됩니다. -### 3. MinIO, BGE-M3, Ollama 실행 +### 3. MinIO, BGE-M3 실행 + Ollama 네이티브 설치 Docker Desktop을 실행한 뒤 로컬 인프라를 기동합니다. 이 구성에서는 `postgres` Service를 실행하지 않습니다. ```bash -docker compose up -d --build minio embedding-server ollama -``` - -Ollama API가 준비될 때까지 Compose Health Check를 기다린 뒤, 루트 `.env`의 `OLLAMA_MODEL`에 지정한 -모델을 최초 한 번 내려받습니다. 값을 생략하면 Spring Boot 기본값인 `qwen2.5:7b`를 사용합니다. - -```bash -docker compose up -d --wait --wait-timeout 120 ollama -OLLAMA_MODEL_NAME=$(sed -n 's/^OLLAMA_MODEL=//p' .env | tail -n 1) -docker compose exec ollama ollama pull "${OLLAMA_MODEL_NAME:-qwen2.5:7b}" +docker compose up -d --build minio embedding-server ``` BGE-M3는 첫 실행 시 약 3GB 모델을 내려받으므로 준비까지 10~15분 정도 걸릴 수 있습니다. 모델이 @@ -126,9 +117,30 @@ docker compose logs -f embedding-server ```bash curl -f http://localhost:8000/health curl -f http://localhost:9000/minio/health/live +``` + +Ollama는 **Docker가 아니라 macOS에 네이티브로 설치**합니다. Docker Desktop for Mac은 컨테이너에 +GPU(Metal)를 넘길 방법이 없어 CPU로만 추론하게 되고, 실제 RAG 프롬프트 기준 50초 이상 걸려 항상 +타임아웃됩니다. GPU(Metal) 가속은 **Apple Silicon Mac 기준**이며, Intel Mac은 네이티브로 설치해도 +CPU로만 추론하므로 동일한 타임아웃 문제가 있습니다. + +```bash +brew install ollama +brew services start ollama +ollama pull qwen2.5:7b curl -f http://localhost:11434/api/tags ``` +`ollama pull`은 모델을 다운로드만 하고 메모리에 올리지는 않습니다. 아래처럼 모델을 한 번 실행해 +로드한 뒤, `ollama ps`의 `PROCESSOR`가 `100% GPU`로 나오는지 확인하세요. + +```bash +ollama run qwen2.5:7b "안녕" +ollama ps +``` + +자세한 내용은 [백엔드 README의 Ollama 절](backend/README.md#ollama-rag-llm-서버)을 참고하세요. + ### 4. Spring Boot 실행 ```bash @@ -189,7 +201,7 @@ npm --prefix frontend run dev | `8000` | 로컬 Docker | BGE-M3 임베딩 서버 | | `9000` | 로컬 Docker | MinIO API | | `9001` | 로컬 Docker | MinIO Console | -| `11434` | 로컬 Docker | Ollama RAG LLM 서버 | +| `11434` | 로컬 (네이티브) | Ollama RAG LLM 서버 | ## 종료 @@ -197,19 +209,21 @@ npm --prefix frontend run dev Docker Service는 다음 명령으로 중지합니다. ```bash -docker compose stop minio embedding-server ollama +docker compose stop minio embedding-server ``` 로컬 PostgreSQL 구성까지 실행했다면 `postgres`도 함께 중지합니다. ```bash -docker compose stop postgres minio embedding-server ollama +docker compose stop postgres minio embedding-server ``` 위 명령은 Container만 중지하고 데이터를 보존합니다. 반면 `docker compose down -v`는 -`postgres17-data`, `minio-data`, `huggingface-cache`, `ollama-data` Volume의 DB·Object·모델 Cache를 +`postgres17-data`, `minio-data`, `huggingface-cache` Volume의 DB·Object·모델 Cache를 삭제할 수 있으므로 일반적인 종료에는 사용하지 마세요. +Ollama는 `brew services stop ollama`로 중지합니다. + ## EC2 없이 로컬 PostgreSQL 사용 EC2 OpenSQL 접근 권한이 없는 기여자는 PostgreSQL 17 + pgvector 0.8.1을 로컬 기준선으로 사용할 수 diff --git a/backend/README.md b/backend/README.md index a37f5c3d..fc1c4e64 100644 --- a/backend/README.md +++ b/backend/README.md @@ -42,14 +42,21 @@ docker compose up -d embedding-server ## Ollama (RAG LLM 서버) -RAG 답변 생성에 사용하는 로컬 LLM(`qwen2.5:7b`) 서버입니다. 공식 이미지를 그대로 사용하므로 별도 build 없이 실행만 하면 됩니다. +RAG 답변 생성에 사용하는 로컬 LLM(`qwen2.5:7b`) 서버입니다. **Docker로 실행하지 않고 macOS에 네이티브로 +설치합니다** — Docker Desktop for Mac은 컨테이너에 GPU(Metal)를 넘길 방법이 없어 CPU로만 추론하게 되고, +실제 RAG 규모 프롬프트 기준 응답이 50초 이상 걸려 항상 타임아웃됩니다. 네이티브로 설치하면 Apple Silicon +Metal 가속을 받아 같은 프롬프트가 10~15초대로 줄어듭니다. ```bash -docker compose up -d --wait --wait-timeout 120 ollama -docker compose exec ollama ollama pull qwen2.5:7b -docker compose exec ollama ollama run qwen2.5:7b "안녕" +brew install ollama +brew services start ollama # 로그인할 때마다 자동 기동 +ollama pull qwen2.5:7b +ollama run qwen2.5:7b "안녕" ``` -- `pull`은 최초 1회만 필요합니다 (약 4.7GB, `ollama-data` 볼륨에 캐시되어 이후 재구동 시 재다운로드하지 않습니다). +- `pull`은 최초 1회만 필요합니다 (약 4.7GB, `~/.ollama`에 캐시됩니다). - 정상 응답이 텍스트로 출력되면 준비 완료입니다. +- `ollama ps`의 `PROCESSOR` 컬럼이 `100% GPU`로 나오는지 확인하세요. `CPU`로 나오면 Metal 가속을 못 + 받고 있는 것이라 응답이 매우 느립니다. - 기본 접속 정보는 `http://localhost:11434`이며, Spring Boot에서는 `OLLAMA_SERVER_URL` 환경변수로 오버라이드할 수 있습니다. +- 중지하려면 `brew services stop ollama`를 실행하세요. diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/request/OllamaGenerateRequest.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/request/OllamaGenerateRequest.java index 3e581769..6155046c 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/request/OllamaGenerateRequest.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/dto/request/OllamaGenerateRequest.java @@ -1,4 +1,24 @@ package com.opensource.docgrid.domain.rag.dto.request; -public record OllamaGenerateRequest(String model, String prompt, boolean stream) { +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OllamaGenerateRequest( + String model, + String prompt, + boolean stream, + // 채팅 템플릿(및 그에 딸린 tool-call PEG 파서)을 거치지 않고 프롬프트를 그대로 전달한다. + // 템플릿을 타면 답변에 섞인 백틱(`ls` 등) 코드 표기를 모델이 tool-call 시도로 오인해 + // 생성이 done:false로 중간에 끊기는 문제가 있었다. + boolean raw, + @JsonProperty("keep_alive") String keepAlive, + OllamaGenerateOptions options +) { + public record OllamaGenerateOptions( + @JsonProperty("num_predict") int numPredict, + double temperature, + @JsonProperty("top_p") double topP, + @JsonProperty("repeat_penalty") double repeatPenalty, + @JsonProperty("repeat_last_n") int repeatLastN + ) { + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java index c1501590..bf2ac476 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/OllamaClient.java @@ -1,13 +1,26 @@ package com.opensource.docgrid.domain.rag.service; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; import com.opensource.docgrid.domain.rag.dto.request.OllamaGenerateRequest; +import com.opensource.docgrid.domain.rag.dto.request.OllamaGenerateRequest.OllamaGenerateOptions; import com.opensource.docgrid.domain.rag.dto.response.OllamaGenerateResponse; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -24,40 +37,217 @@ @Service public class OllamaClient { + // eval_count(실제 생성된 토큰 수)가 num_predict에 도달했다는 건 모델이 할 말을 다 못 하고 + // 토큰 상한에 걸려 끊겼다는 확정적 신호다 — LLM이 스스로 이를 감지·보고하게 하는 것보다 신뢰할 수 있다. + private static final String TRUNCATION_NOTICE = + "\n\n(※ 답변이 길어 일부 내용이 생략됐을 수 있습니다. 자세한 내용은 문서를 확인해주세요.)"; + + // Ollama의 NDJSON 청크에는 created_at, total_duration 등 우리가 매핑하지 않는 필드가 있다. + private static final ObjectMapper CHUNK_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // 한국어 RAG 답변에 한자·히라가나·가타카나가 나올 일은 없다. qwen 계열의 code-switching으로 + // 섞여 나온 문자를 프롬프트 지시(모델이 무시할 수 있음)가 아닌 코드로 제거한다. + private static final Pattern FOREIGN_CJK_PATTERN = + Pattern.compile("[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}]+"); + + // 혼입이 이 글자 수를 넘으면 낱자 노이즈가 아니라 모델이 중국어로 넘어가 무너진 구간으로 판단하고, + // 문자만 지워 구두점 뼈대를 남기는 대신 혼입 시작 지점에서 답변을 자른다. + private static final int FOREIGN_CJK_CUT_THRESHOLD = 8; + private final String model; + private final String keepAlive; + private final int numPredict; + private final double temperature; + private final double topP; + private final double repeatPenalty; + private final int repeatLastN; + private final Duration generateDeadline; private final RestClient restClient; public OllamaClient( @Value("${ollama.model}") String model, + @Value("${ollama.keep-alive}") String keepAlive, + @Value("${ollama.num-predict}") int numPredict, + @Value("${ollama.temperature}") double temperature, + @Value("${ollama.top-p}") double topP, + @Value("${ollama.repeat-penalty}") double repeatPenalty, + @Value("${ollama.repeat-last-n}") int repeatLastN, + @Value("${ollama.generate-deadline}") Duration generateDeadline, @Qualifier("ollamaRestClient") RestClient restClient ) { this.model = model; + this.keepAlive = keepAlive; + this.numPredict = numPredict; + this.temperature = temperature; + this.topP = topP; + this.repeatPenalty = repeatPenalty; + this.repeatLastN = repeatLastN; + this.generateDeadline = generateDeadline; this.restClient = restClient; } public OllamaGenerateResult generate(String prompt) { long start = System.currentTimeMillis(); + long deadline = start + generateDeadline.toMillis(); - OllamaGenerateResponse response; + StreamChunks chunks; try { - response = restClient.post() + chunks = restClient.post() .uri("/api/generate") - .body(new OllamaGenerateRequest(model, prompt, false)) - .retrieve() - .body(OllamaGenerateResponse.class); + .body(new OllamaGenerateRequest( + model, prompt, true, true, keepAlive, + new OllamaGenerateOptions(numPredict, temperature, topP, repeatPenalty, repeatLastN) + )) + .exchange((request, response) -> { + if (response.getStatusCode().isError()) { + log.error("Ollama 서버 오류 응답: status={}", response.getStatusCode()); + throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); + } + return readStream(response.getBody(), deadline); + }); } catch (RestClientException e) { log.error("Ollama 서버 호출 실패: {}", e.getMessage()); throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); } - if (response == null || response.response() == null) { - log.error("Ollama 응답이 비어있음: response={}", response); + // 한 토큰도 못 받았으면 부분 답변 반환 대신 예외를 던져 상위의 extractive fallback에 맡긴다. + if (chunks.last() == null || chunks.answer().isBlank()) { + log.error("Ollama 스트리밍 응답에서 답변을 받지 못함: deadlineExceeded={}", chunks.deadlineExceeded()); throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); } + // done:true 없이 스트림이 끝나는 경우가 있다: 데드라인 조기 종료 외에도, Ollama의 PEG 파서가 + // 한글이 토큰 경계에서 바이트 단위로 쪼개진 출력을 파싱하지 못하고 생성을 취소하는 버그 + // (llama.cpp #24807)가 확인됐다. 발생 빈도를 추적할 수 있게 경고 로그를 남긴다. + boolean prematureEnd = !chunks.last().done(); + if (prematureEnd && !chunks.deadlineExceeded()) { + log.warn("Ollama 스트림이 done 없이 조기 종료됨(서버 측 생성 취소 추정): 수신 텍스트 길이={}", chunks.answer().length()); + } + + SanitizedAnswer sanitized = sanitizeAnswer(chunks.answer()); + if (sanitized.text().isBlank()) { + log.error("한자/가나 혼입 처리 후 답변이 비어 있음"); + throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); + } + + String answerText = sanitized.text(); + boolean hitTokenLimit = chunks.last().evalCount() != null && chunks.last().evalCount() >= numPredict; + if (hitTokenLimit || prematureEnd || sanitized.cutAtMixing()) { + answerText = trimToSentenceBoundary(answerText) + TRUNCATION_NOTICE; + } + int latencyMs = (int) (System.currentTimeMillis() - start); return new OllamaGenerateResult( - response.model(), response.response(), response.promptEvalCount(), response.evalCount(), latencyMs + chunks.last().model(), answerText, chunks.last().promptEvalCount(), chunks.last().evalCount(), latencyMs ); } + + /** + * NDJSON 스트림을 청크 단위로 읽어 답변을 누적한다. 데드라인을 넘기면 읽기를 중단하고 + * 그때까지 모인 부분 답변을 반환한다 — 전체 응답에 read-timeout을 걸던 방식과 달리, + * 디코드가 느려져도 이미 생성된 내용을 잃지 않는다. 조기 반환으로 스트림이 닫히면 + * Ollama가 클라이언트 이탈을 감지하고 생성을 중단하므로 자원도 낭비되지 않는다. + */ + private StreamChunks readStream(InputStream body, long deadline) throws IOException { + StringBuilder answer = new StringBuilder(); + OllamaGenerateResponse last = null; + boolean deadlineExceeded = false; + BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8)); + try { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + OllamaGenerateResponse chunk = CHUNK_MAPPER.readValue(line, OllamaGenerateResponse.class); + if (chunk.response() != null) { + answer.append(chunk.response()); + } + last = chunk; + if (chunk.done()) { + break; + } + if (System.currentTimeMillis() >= deadline) { + deadlineExceeded = true; + break; + } + } + } catch (IOException e) { + // 스트림이 멈춰 read-timeout이 본문 연결을 끊는 경우 등. 이미 받은 부분 답변이 있으면 + // 버리지 않고 done 없는 조기 종료로 처리해 반환하고, 하나도 없을 때만 실패로 전파한다. + if (answer.isEmpty()) { + throw e; + } + log.warn("Ollama 스트림 읽기 중단, 수신된 부분 답변 반환: 길이={}, 원인={}", answer.length(), e.getMessage()); + } + return new StreamChunks(answer.toString(), last, deadlineExceeded); + } + + private record StreamChunks(String answer, OllamaGenerateResponse last, boolean deadlineExceeded) { + } + + /** + * 답변에 섞인 한자/가나를 처리한다. 낱자 수준의 혼입은 해당 문자만 제거하고, 대량 혼입은 + * 모델이 중국어 반복 루프로 넘어간 것이므로 혼입 시작 지점에서 답변을 잘라 잘림으로 처리한다. + * 발생 빈도를 추적할 수 있게 감지 시 경고 로그를 남긴다. + */ + private static SanitizedAnswer sanitizeAnswer(String text) { + // 전각 구두점은 문장 부호 역할을 유지해야 하므로 삭제하지 않고 반각으로 치환한다. + String normalized = text + .replace('。', '.').replace('、', ',').replace(':', ':') + .replace(',', ',').replace('!', '!').replace('?', '?'); + Matcher matcher = FOREIGN_CJK_PATTERN.matcher(normalized); + if (!matcher.find()) { + return new SanitizedAnswer(normalized, false); + } + int firstMixIndex = matcher.start(); + int mixedCount = matcher.group().length(); + while (matcher.find()) { + mixedCount += matcher.group().length(); + } + if (mixedCount > FOREIGN_CJK_CUT_THRESHOLD) { + log.warn("답변에 한자/가나 대량 혼입({}자) 감지, 혼입 시작 지점에서 잘라냄", mixedCount); + return new SanitizedAnswer(normalized.substring(0, firstMixIndex), true); + } + log.warn("답변에 한자/가나 혼입({}자) 감지, 제거함", mixedCount); + return new SanitizedAnswer(FOREIGN_CJK_PATTERN.matcher(normalized).replaceAll(""), false); + } + + private record SanitizedAnswer(String text, boolean cutAtMixing) { + } + + /** + * 토큰 상한에 걸려 잘린 답변을 마지막 완결 문장까지만 남긴다. 단어 중간에서 뚝 끊긴 꼬리를 + * 제거해 의도적으로 요약한 것처럼 보이게 한다. 문장 경계를 하나도 못 찾으면 원문을 그대로 + * 반환한다. + */ + private static String trimToSentenceBoundary(String text) { + for (int i = text.length() - 1; i >= 0; i--) { + char c = text.charAt(i); + if ((c == '!' || c == '?' || (c == '.' && isSentenceEndDot(text, i))) && !insideInlineCode(text, i)) { + return text.substring(0, i + 1); + } + } + return text; + } + + // 숫자 목록 마커("6.")나 경로 표기(".." 등 연속 마침표)의 마침표는 문장 끝이 아니다. + private static boolean isSentenceEndDot(String text, int i) { + boolean precededOk = i == 0 + || (text.charAt(i - 1) != '.' && !Character.isDigit(text.charAt(i - 1))); + boolean followedOk = i == text.length() - 1 || text.charAt(i + 1) != '.'; + return precededOk && followedOk; + } + + // 백틱 코드 스팬(`taskkill /PID candidates) { StringBuilder sb = new StringBuilder(INSTRUCTION); @@ -39,6 +47,8 @@ public String build(String queryText, List candidates) { sb.append(citationLine(i + 1, candidate, chunkTextLimit)).append('\n'); } sb.append("\n질문: ").append(queryText); + sb.append("\n\n(다시 한번 강조: 답변은 한국어로만 작성하세요. 답변을 마쳤으면 같은 내용을 다른 언어로 " + + "번역하거나 반복해서 덧붙이지 말고 그대로 끝내세요.)"); return sb.toString(); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java index 7567cf0f..14d06a1e 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java @@ -39,8 +39,20 @@ @Slf4j public class RagFacade { - private static final String LLM_FALLBACK_ANSWER = - "관련 문서는 찾았지만 AI 답변 생성이 지연되고 있습니다. 아래 검색 결과와 근거 문서를 확인해 주세요."; + private static final String LLM_FALLBACK_PREFIX = "AI 답변 생성이 지연되고 있습니다. " + + "가장 관련도 높은 문서에서 다음 내용을 찾았습니다:\n\n"; + + // fallback 문구에 원문을 통째로 붙이면 답변이 지나치게 길어져, 미리보기 수준으로만 잘라 보여준다. + private static final int FALLBACK_EXCERPT_MAX_CODE_POINTS = 300; + + // topK는 호출자가 1~20까지 자유롭게 요청할 수 있어(SearchRequest), 후보 수를 그대로 프롬프트에 + // 다 넣으면 prefill 시간이 예측 불가능해져 read-timeout(25s)을 넘기는 경우가 생긴다. + // 화면에 보여줄 인용 문서 수(topK)와 별개로, LLM이 실제로 읽는 후보 수는 이 값으로 고정한다. + private static final int MAX_PROMPT_CANDIDATES = 3; + + // PromptBuilder가 LLM에게 무관한 문서일 때 이 문구로만 답하도록 지시한다 — 검색은 됐지만(candidates + // 존재) LLM이 무관하다고 판단한 경우, 화면에 근거 문서를 같이 보여주면 안내 문구와 모순돼 보인다. + private static final String NO_RELEVANT_DOC_PHRASE = "관련 문서를 찾지 못했습니다"; private final PromptBuilder promptBuilder; private final OllamaClient ollamaClient; @@ -60,22 +72,57 @@ public RagAnswer generate( return RagAnswer.noContext(ragResponse.getAnswerText()); } - // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 - String prompt = promptBuilder.build(queryText, candidates); + // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 (LLM 입력은 상위 MAX_PROMPT_CANDIDATES개로 제한) + List promptCandidates = candidates.size() > MAX_PROMPT_CANDIDATES + ? candidates.subList(0, MAX_PROMPT_CANDIDATES) + : candidates; + String prompt = promptBuilder.build(queryText, promptCandidates); OllamaGenerateResult result; try { result = ollamaClient.generate(prompt); } catch (DocGridException e) { ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage()); - // LLM 장애가 권한 검증을 통과한 벡터 검색 결과까지 숨기지 않도록 저하 응답으로 마무리한다. + // LLM 장애가 권한 검증을 통과한 벡터 검색 결과까지 숨기지 않도록, 최상위 후보 원문을 + // 그대로 인용해 최소한의 답을 제공한다(extractive fallback). log.warn("[RAG] fallback queryId={} errorCode={}", queryId, e.getErrorCode().getCode()); - return RagAnswer.of(LLM_FALLBACK_ANSWER, candidates); + return RagAnswer.of(buildExtractiveFallbackAnswer(candidates), candidates); } // LLM 이후의 영속화 실패는 검색 저하 응답으로 숨기지 않고 Transaction 오류로 전달한다. RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); - return RagAnswer.of(result.answerText(), candidates); + + // LLM이 무관하다고 판단해 안내 문구로만 답했으면, 후보 문서를 근거처럼 같이 보여주지 않는다. + // 단, 7B 모델이 정상 답변을 끝낸 뒤 지시문을 메아리처럼 이 문구를 덧붙이는 패턴이 관찰됨 — + // 문구가 답변의 사실상 전부(맨 앞)일 때만 무관으로 취급하고, 정상 답변 중간에 박힌 문구는 + // 그 지점부터 잘라내고 근거 문서는 유지한다. + String answerText = result.answerText(); + int phraseIndex = answerText != null ? answerText.indexOf(NO_RELEVANT_DOC_PHRASE) : -1; + if (phraseIndex >= 0) { + if (answerText.strip().startsWith(NO_RELEVANT_DOC_PHRASE)) { + return RagAnswer.of(answerText, List.of()); + } + log.warn("[RAG] 정상 답변에 무관 안내 문구 혼입, 해당 지점부터 제거: queryId={} phraseIndex={}", + queryId, phraseIndex); + answerText = answerText.substring(0, phraseIndex).strip(); + } + return RagAnswer.of(answerText, candidates); + } + + private String buildExtractiveFallbackAnswer(List candidates) { + VectorSearchCandidate top = candidates.get(0); + String pageSuffix = top.pageNo() != null ? " " + top.pageNo() + "페이지" : ""; + String excerpt = truncate(top.chunkText(), FALLBACK_EXCERPT_MAX_CODE_POINTS); + return "%s\"%s\" (%s%s)".formatted(LLM_FALLBACK_PREFIX, excerpt, top.documentTitle(), pageSuffix); + } + + private String truncate(String text, int maxCodePoints) { + int codePointCount = text.codePointCount(0, text.length()); + if (codePointCount <= maxCodePoints) { + return text; + } + int endIndex = text.offsetByCodePoints(0, maxCodePoints - 1); + return text.substring(0, endIndex) + "…"; } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 21f8cffd..4c6b9ad1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -111,6 +111,28 @@ ollama: server: base-url: ${OLLAMA_SERVER_URL:http://localhost:11434} # 프론트의 29초 및 Sites Worker의 30초 제한 전에 검색 결과 Fallback을 반환한다. + # Docker 컨테이너(GPU 미가속) 기준이 아니라 macOS 네이티브 Ollama(Metal 가속) 실측 기준값이다. connect-timeout: ${OLLAMA_SERVER_CONNECT_TIMEOUT:3s} - read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:18s} + # 요청 시작부터 스트리밍 본문 수신까지 전체에 적용되는 전송 계층 제한(Spring JdkClientHttpRequestFactory가 + # 본문 스트림에도 적용). 스트림이 멈췄을 때의 최후 방어선이며, 이때도 이미 받은 부분 답변은 보존된다. + # 정상 스트림의 시간 상한은 ollama.generate-deadline(25s)이 먼저 담당하므로 이 값은 그보다 커야 한다. + read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:27s} model: ${OLLAMA_MODEL:qwen2.5:7b} + # 요청 사이 모델을 상주시켜 매 요청마다 재로딩(수 초)이 발생하지 않게 한다. + keep-alive: ${OLLAMA_KEEP_ALIVE:30m} + # 생성 토큰 수 상한. 시간 상한은 generate-deadline이 담당하므로 여기는 답변이 무한정 길어지는 + # 것만 막는다 — 스트리밍 전환으로 250까지 조이던 것을 완화했다 (디코드가 빠른 세션에서는 더 긴 답변 허용). + num-predict: ${OLLAMA_NUM_PREDICT:400} + # 스트리밍 생성의 전체 데드라인. 초과 시 요청을 끊고 그때까지 받은 부분 답변을 잘림 안내와 함께 + # 반환한다. 프론트 29초 제한보다 확실히 작아야 프론트가 포기하기 전에 응답할 수 있다. + generate-deadline: ${OLLAMA_GENERATE_DEADLINE:25s} + # RAG는 문서 내용을 그대로 답하는 용도라 창의성이 불필요하다. 낮은 temperature/top-p로 + # 확률 꼬리의 한자·가나 토큰이 뽑힐 확률을 줄여 한국어 답변에 중국어/일본어가 섞이는 것을 완화한다. + temperature: ${OLLAMA_TEMPERATURE:0.3} + top-p: ${OLLAMA_TOP_P:0.8} + # Ollama 기본값이 1.0(반복 억제 없음)으로 확인됨 — 모델이 답을 끝내고도 "한국어로만 답변했습니다"류의 + # 잡담을 반복하며 토큰 상한까지 채우는 루프를 억제한다. + repeat-penalty: ${OLLAMA_REPEAT_PENALTY:1.1} + # repeat_penalty가 되돌아보는 토큰 창. 기본 64로는 64토큰보다 긴 블록이 통째로 반복되는 것을 + # 못 잡아서 넓혔다. 목록형 답변의 정당한 반복 표현이 어색해지면 128로 낮춰볼 것. + repeat-last-n: ${OLLAMA_REPEAT_LAST_N:256} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java index 7bbbc4bc..9740336e 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/OllamaClientTest.java @@ -2,8 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -14,11 +23,13 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse; +import org.springframework.web.client.RestClient.RequestHeadersSpec.ExchangeFunction; import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; -import com.opensource.docgrid.domain.rag.dto.response.OllamaGenerateResponse; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -29,24 +40,40 @@ class OllamaClientTest { @Mock private RestClient restClient; @Mock(answer = Answers.RETURNS_SELF) private RestClient.RequestBodyUriSpec requestBodyUriSpec; - @Mock private RestClient.ResponseSpec responseSpec; private OllamaClient ollamaClient; @BeforeEach void setUp() { - ollamaClient = new OllamaClient("qwen2.5:3b", restClient); + ollamaClient = new OllamaClient( + "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), restClient + ); doReturn(requestBodyUriSpec).when(restClient).post(); - doReturn(responseSpec).when(requestBodyUriSpec).retrieve(); + } + + /** exchange()에 넘어온 함수를 주어진 NDJSON 스트림 응답으로 즉시 실행하도록 스텁한다. */ + private void givenStreamBody(String ndjson) { + givenStreamBody(new ByteArrayInputStream(ndjson.getBytes(StandardCharsets.UTF_8))); + } + + private void givenStreamBody(InputStream body) { + doAnswer(invocation -> { + ExchangeFunction fn = invocation.getArgument(0); + ConvertibleClientHttpResponse response = mock(ConvertibleClientHttpResponse.class); + doReturn(HttpStatus.OK).when(response).getStatusCode(); + doReturn(body).when(response).getBody(); + return fn.exchange(null, response); + }).when(requestBodyUriSpec).exchange(any()); } @Test - @DisplayName("정상 케이스: 프롬프트를 전달하면 답변 텍스트와 토큰 수를 반환한다") + @DisplayName("정상 케이스: NDJSON 청크를 누적해 답변 텍스트와 토큰 수를 반환한다") void generate_success() { - OllamaGenerateResponse serverResponse = new OllamaGenerateResponse( - "qwen2.5:3b", "연차는 입사 1년 기준 15일 부여됩니다.", true, 120, 45 - ); - given(responseSpec.body(OllamaGenerateResponse.class)).willReturn(serverResponse); + givenStreamBody(""" + {"model":"qwen2.5:3b","created_at":"2026-08-16T00:00:00Z","response":"연차는 입사 1년 기준 ","done":false} + {"model":"qwen2.5:3b","response":"15일 부여됩니다.","done":false} + {"model":"qwen2.5:3b","response":"","done":true,"prompt_eval_count":120,"eval_count":45} + """); OllamaGenerateResult result = ollamaClient.generate("질문: 연차 규정 알려줘"); @@ -57,11 +84,190 @@ void generate_success() { assertThat(result.latencyMs()).isGreaterThanOrEqualTo(0); } + @Test + @DisplayName("토큰 상한 도달: eval_count가 num_predict 이상이면 잘림 안내 문구를 덧붙인다") + void generate_hitsNumPredict_appendsTruncationNotice() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"1. 첫 항목 2. 둘째 항목","done":false} + {"model":"qwen2.5:3b","response":"","done":true,"prompt_eval_count":120,"eval_count":300} + """); + + OllamaGenerateResult result = ollamaClient.generate("질문: 명령어 다 알려줘"); + + assertThat(result.answerText()) + .startsWith("1. 첫 항목 2. 둘째 항목") + .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + } + + @Test + @DisplayName("토큰 상한 도달: 단어 중간에서 끊긴 꼬리는 마지막 완결 문장까지만 남기고 잘라낸다") + void generate_hitsNumPredict_trimsToLastSentence() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"5. 상태 확인은 `git status`를 사용합니다. 6. 병합하려면 `git merge` 명령","done":false} + {"model":"qwen2.5:3b","response":"","done":true,"prompt_eval_count":120,"eval_count":300} + """); + + OllamaGenerateResult result = ollamaClient.generate("질문: git 명령어 알려줘"); + + assertThat(result.answerText()) + .startsWith("5. 상태 확인은 `git status`를 사용합니다.") + .doesNotContain("6. 병합하려면") + .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + } + + @Test + @DisplayName("데드라인 초과: 스트림을 중단하고 그때까지 받은 부분 답변에 잘림 안내를 덧붙인다") + void generate_deadlineExceeded_returnsPartialAnswer() { + ollamaClient = new OllamaClient( + "qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ZERO, restClient + ); + doReturn(requestBodyUriSpec).when(restClient).post(); + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"연차는 15일입니다. 그리고 추가","done":false} + {"model":"qwen2.5:3b","response":"로 이월 규정이","done":false} + {"model":"qwen2.5:3b","response":"","done":true,"prompt_eval_count":120,"eval_count":45} + """); + + OllamaGenerateResult result = ollamaClient.generate("질문: 연차 규정 알려줘"); + + assertThat(result.answerText()) + .startsWith("연차는 15일입니다.") + .doesNotContain("이월 규정") + .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + } + + @Test + @DisplayName("서버 측 생성 취소: done 없이 스트림이 끝나면 마지막 완결 문장까지 남기고 잘림 안내를 덧붙인다") + void generate_prematureStreamEnd_treatsAsTruncation() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"ls 명령어는 디렉토리 내용을 출력합니다. `ls ../test2` : 부모","done":false} + """); + + OllamaGenerateResult result = ollamaClient.generate("질문: ls 명령어 알려줘"); + + assertThat(result.answerText()) + .startsWith("ls 명령어는 디렉토리 내용을 출력합니다.") + .doesNotContain("부모") + .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + } + + @Test + @DisplayName("스트림 정지: 읽기 중 IOException이 발생해도 이미 받은 부분 답변을 잘림으로 반환한다") + void generate_streamStalled_returnsPartialAnswer() { + byte[] data = "{\"model\":\"qwen2.5:3b\",\"response\":\"연차는 15일 부여됩니다. 이월 규\",\"done\":false}\n" + .getBytes(StandardCharsets.UTF_8); + InputStream stalledBody = new InputStream() { + private int pos = 0; + + @Override + public int read() throws IOException { + if (pos < data.length) { + return data[pos++] & 0xFF; + } + throw new IOException("stream stalled"); + } + }; + givenStreamBody(stalledBody); + + OllamaGenerateResult result = ollamaClient.generate("질문: 연차 규정 알려줘"); + + assertThat(result.answerText()) + .startsWith("연차는 15일 부여됩니다.") + .doesNotContain("이월 규") + .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + } + + @Test + @DisplayName("문장 경계 트리밍: 백틱 코드 스팬(`ls .`) 안의 마침표는 문장 끝으로 오인하지 않는다") + void generate_trims_ignoresDotInsideInlineCode() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"1. `ls .` : 현재 디렉토리의 내용을 출력합니다. 2. `ls .` : 현재 디렉","done":false} + """); + + OllamaGenerateResult result = ollamaClient.generate("질문: ls 명령어 알려줘"); + + assertThat(result.answerText()) + .startsWith("1. `ls .` : 현재 디렉토리의 내용을 출력합니다.") + .doesNotContain("2. `ls .`") + .contains("답변이 길어 일부 내용이 생략됐을 수 있습니다"); + } + + @Test + @DisplayName("언어 혼입: 답변에 섞인 한자/가나 문자를 제거하고 한국어만 남긴다") + void generate_stripsForeignCjkCharacters() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"연차는 입사 1년 기준 15일 부여되며中文が混入 다음 해로 이월됩니다。","done":false} + {"model":"qwen2.5:3b","response":"","done":true,"prompt_eval_count":120,"eval_count":45} + """); + + OllamaGenerateResult result = ollamaClient.generate("질문: 연차 규정 알려줘"); + + assertThat(result.answerText()) + .isEqualTo("연차는 입사 1년 기준 15일 부여되며 다음 해로 이월됩니다.") + .doesNotContain("中文", "混入", "が", "。"); + } + + @Test + @DisplayName("문장 경계 트리밍: 백틱 코드 스팬 안의 물음표는 문장 끝으로 오인하지 않는다") + void generate_trims_ignoresQuestionMarkInsideInlineCode() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"포트 확인은 `netstat`을 사용합니다. 이후 `taskkill -F -PID ollamaClient.generate("질문")) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.RAG_SERVICE_UNAVAILABLE); + } + + @Test + @DisplayName("빈 응답: 스트림에서 청크를 하나도 받지 못하면 RAG_SERVICE_UNAVAILABLE 예외가 발생한다") + void generate_emptyStream_throwsException() { + givenStreamBody(""); assertThatThrownBy(() -> ollamaClient.generate("질문")) .isInstanceOf(DocGridException.class) @@ -69,9 +275,11 @@ void generate_serverUnavailable_throwsException() { } @Test - @DisplayName("빈 응답: 응답 body가 null이면 RAG_SERVICE_UNAVAILABLE 예외가 발생한다") - void generate_nullResponse_throwsException() { - given(responseSpec.body(OllamaGenerateResponse.class)).willReturn(null); + @DisplayName("빈 응답: 답변 텍스트 없이 done만 오면 RAG_SERVICE_UNAVAILABLE 예외가 발생한다") + void generate_blankAnswer_throwsException() { + givenStreamBody(""" + {"model":"qwen2.5:3b","response":"","done":true,"prompt_eval_count":10,"eval_count":0} + """); assertThatThrownBy(() -> ollamaClient.generate("질문")) .isInstanceOf(DocGridException.class) @@ -79,10 +287,14 @@ void generate_nullResponse_throwsException() { } @Test - @DisplayName("빈 응답: response 필드가 null이면 RAG_SERVICE_UNAVAILABLE 예외가 발생한다") - void generate_nullAnswerText_throwsException() { - OllamaGenerateResponse serverResponse = new OllamaGenerateResponse("qwen2.5:3b", null, true, 10, 0); - given(responseSpec.body(OllamaGenerateResponse.class)).willReturn(serverResponse); + @DisplayName("서버 오류 상태: 5xx 응답이면 RAG_SERVICE_UNAVAILABLE 예외가 발생한다") + void generate_errorStatus_throwsException() { + doAnswer(invocation -> { + ExchangeFunction fn = invocation.getArgument(0); + ConvertibleClientHttpResponse response = mock(ConvertibleClientHttpResponse.class); + doReturn(HttpStatus.INTERNAL_SERVER_ERROR).when(response).getStatusCode(); + return fn.exchange(null, response); + }).when(requestBodyUriSpec).exchange(any()); assertThatThrownBy(() -> ollamaClient.generate("질문")) .isInstanceOf(DocGridException.class) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/PromptBuilderTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/PromptBuilderTest.java index d9233bdf..9bfd08fc 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/PromptBuilderTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/PromptBuilderTest.java @@ -59,11 +59,15 @@ void build_alwaysIncludesInstructionAndQuestion() { // Then assertThat(prompt) - .contains("질문과 문서가 직접 관련 있는지 판단하세요") + .contains("문서 내용이 질문 주제와 실제로 관련 있는지 판단하세요") .contains("단순히 일부 단어가 겹친다는 이유만으로 관련 있다고 판단하지 마세요") .contains("관련 문서를 찾지 못했습니다.\"라고만 답하세요") + .contains("관련된 항목을 빠짐없이 구체적으로 정리해서 답변하세요") + .contains("문서가 무엇에 대한 내용인지 3~4문장 이내로 간결하게 설명하세요") .contains("문서에 없는 내용은 일반 지식이나 추측으로 보완하지 마세요") - .contains("질문: 질문 내용"); + .contains("답변은 반드시 한국어로만 작성하세요") + .contains("질문: 질문 내용") + .contains("번역하거나 반복해서 덧붙이지 말고 그대로 끝내세요"); } @Test @@ -81,7 +85,7 @@ void build_longChunk_limitsChunkTextLength() { } @Test - @DisplayName("후보가 많아도 모든 라벨을 유지하며 전체 청크 본문을 6000자로 제한한다") + @DisplayName("후보가 많아도 모든 라벨을 유지하며 전체 청크 본문을 3200자로 제한한다") void build_manyCandidates_sharesContextBudgetAndKeepsLabels() { List candidates = java.util.stream.LongStream.rangeClosed(1, 20) .mapToObj(id -> new VectorSearchCandidate( @@ -97,7 +101,7 @@ void build_manyCandidates_sharesContextBudgetAndKeepsLabels() { .mapToLong(text -> text.codePointCount(0, text.length())) .sum(); assertThat(prompt).contains("[1]", "[20]"); - assertThat(contextCodePoints).isEqualTo(6_000L); + assertThat(contextCodePoints).isEqualTo(3_200L); assertThat(prompt.codePoints().filter(codePoint -> codePoint == '…').count()).isEqualTo(20L); } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java index 45672f43..63336008 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java @@ -117,8 +117,8 @@ void generate_success_savesResponseAndCitations() { } @Test - @DisplayName("Ollama 호출 실패: FAILED로 기록하고 검색 후보가 포함된 저하 응답을 반환한다") - void generate_ollamaFails_savesFailedAndReturnsFallback() { + @DisplayName("Ollama 호출 실패: FAILED로 기록하고 최상위 후보 원문을 인용한 extractive fallback을 반환한다") + void generate_ollamaFails_savesFailedAndReturnsExtractiveFallback() { SearchQuery queryRef = mock(SearchQuery.class); given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); @@ -134,6 +134,8 @@ void generate_ollamaFails_savesFailedAndReturnsFallback() { RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, List.of()); assertThat(answer.answerText()).contains("AI 답변 생성이 지연"); + assertThat(answer.answerText()).contains("청크 내용"); + assertThat(answer.answerText()).contains("인사규정"); assertThat(answer.citations()).hasSize(1); assertThat(answer.citations().get(0).documentId()).isEqualTo(100L); @@ -141,4 +143,93 @@ void generate_ollamaFails_savesFailedAndReturnsFallback() { .createFailed(eq(queryRef), eq("조립된 프롬프트"), anyString()); then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); } + + @Test + @DisplayName("LLM 무관 판단: 답변이 안내 문구면 citation을 저장은 하되 반환 answer에는 포함하지 않는다") + void generate_llmJudgesIrrelevant_returnsEmptyCitations() { + 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(anyString(), eq(candidates))).willReturn("조립된 프롬프트"); + OllamaGenerateResult ollamaResult = + new OllamaGenerateResult("qwen2.5:3b", "관련 문서를 찾지 못했습니다.", 100, 10, 500); + given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); + RagResponse ragResponse = RagResponse.builder() + .answerText("관련 문서를 찾지 못했습니다.") + .status(ResultStatus.SUCCESS) + .build(); + given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); + + RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, searchResults); + + assertThat(answer.answerText()).isEqualTo("관련 문서를 찾지 못했습니다."); + assertThat(answer.citations()).isEmpty(); + then(responseCitationCommandService).should(times(1)).saveAll(ragResponse, candidates, searchResults); + } + + @Test + @DisplayName("무관 문구 혼입: 정상 답변 중간에 안내 문구가 섞이면 그 지점부터 제거하고 citation은 유지한다") + void generate_phraseEmbeddedInAnswer_stripsPhraseAndKeepsCitations() { + 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(anyString(), eq(candidates))).willReturn("조립된 프롬프트"); + String answerWithEcho = "pwd는 현재 디렉토리를 출력합니다. " + + "관련 문서를 찾지 못했습니다. 질문 주제와 관련된 문서가 없습니다."; + OllamaGenerateResult ollamaResult = + new OllamaGenerateResult("qwen2.5:3b", answerWithEcho, 100, 50, 500); + given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); + RagResponse ragResponse = RagResponse.builder() + .answerText(answerWithEcho) + .status(ResultStatus.SUCCESS) + .build(); + given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); + + RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, searchResults); + + assertThat(answer.answerText()).isEqualTo("pwd는 현재 디렉토리를 출력합니다."); + assertThat(answer.citations()).hasSize(1); + } + + @Test + @DisplayName("후보 상한: 검색 후보가 3개를 넘으면 LLM에는 상위 3개만 전달한다") + void generate_moreThanMaxPromptCandidates_truncatesForPrompt() { + SearchQuery queryRef = mock(SearchQuery.class); + given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); + + List candidates = List.of( + new VectorSearchCandidate(1L, 10L, 100L, "청크1", 1, "문서1", new BigDecimal("0.9")), + new VectorSearchCandidate(2L, 20L, 200L, "청크2", 2, "문서2", new BigDecimal("0.8")), + new VectorSearchCandidate(3L, 30L, 300L, "청크3", 3, "문서3", new BigDecimal("0.7")), + new VectorSearchCandidate(4L, 40L, 400L, "청크4", 4, "문서4", new BigDecimal("0.6")), + new VectorSearchCandidate(5L, 50L, 500L, "청크5", 5, "문서5", new BigDecimal("0.5")) + ); + List expectedPromptCandidates = candidates.subList(0, 3); + + given(promptBuilder.build(anyString(), eq(expectedPromptCandidates))).willReturn("조립된 프롬프트"); + OllamaGenerateResult ollamaResult = new OllamaGenerateResult("qwen2.5:3b", "답변", 100, 20, 900); + given(ollamaClient.generate("조립된 프롬프트")).willReturn(ollamaResult); + RagResponse ragResponse = RagResponse.builder().answerText("답변").status(ResultStatus.SUCCESS).build(); + given(ragResponseCommandService.createSuccess(queryRef, "조립된 프롬프트", ollamaResult)).willReturn(ragResponse); + + RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, List.of()); + + assertThat(answer.citations()).hasSize(5); + then(promptBuilder).should(times(1)).build(anyString(), eq(expectedPromptCandidates)); + then(responseCitationCommandService).should(times(1)).saveAll(ragResponse, candidates, List.of()); + } } diff --git a/docker-compose.yml b/docker-compose.yml index b3726a80..661b2c0b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,22 +58,6 @@ services: networks: - docgrid-local - ollama: - image: ollama/ollama - container_name: docgrid-ollama - ports: - - "127.0.0.1:11434:11434" - volumes: - - ollama-data:/root/.ollama - healthcheck: - test: ["CMD-SHELL", "ollama list || exit 1"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 60s - networks: - - docgrid-local - redis: image: redis:7-alpine container_name: docgrid-redis @@ -97,5 +81,4 @@ volumes: name: docgrid_postgres17_data minio-data: huggingface-cache: - ollama-data: redis-data: diff --git a/docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md b/docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md new file mode 100644 index 00000000..ece26967 --- /dev/null +++ b/docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md @@ -0,0 +1,370 @@ +# #210 Ollama RAG 응답 지연/타임아웃 수정 + +## 배경 + +RAG 답변 생성(Ollama 호출)이 자주 타임아웃되거나, 답변이 중간에 끊기거나, 엉뚱한 언어(중국어)로 +새거나, 무관한 검색 결과를 근거 문서인 것처럼 보여주는 문제가 QA 중 다수 발견됐다. 원래 버그 +리포트는 Docker로 띄운 Ollama가 GPU 가속을 못 받아 CPU 전용 추론으로 50초 이상 걸려 항상 +타임아웃 나는 것이 출발점이었다. + +## 0단계 — 세션 시작 전 상태 (원래 버그 리포트) + +- Ollama를 Docker 컨테이너로 실행 → Docker Desktop for Mac은 컨테이너에 Metal GPU를 못 넘김 → + CPU 전용 추론 +- `read-timeout: 18s`, 실제 RAG 프롬프트 기준 50초 이상 소요 → 항상 타임아웃 +- 모델 cold load에만 8.98~10.78초, 18.026초 지점에 강제 취소되는 사례 확인됨 + +## 1단계 — Ollama Docker → macOS 네이티브 전환 + +Docker Desktop for Mac은 컨테이너에 GPU(Metal)를 넘길 방법이 없어 CPU로만 추론하게 되므로, +Ollama를 macOS에 네이티브로 설치(`brew install ollama`)해 Apple Silicon Metal 가속을 받도록 +전환했다. `docker-compose.yml`에서 `ollama` 서비스/볼륨을 제거했다. + +### 벤치마크 — 원본 명령어와 결과 + +**Docker(CPU) — 명령어** +```bash +docker run -d --name docgrid-ollama-benchmark \ + -p 127.0.0.1:11435:11434 \ + -v docgrid_ollama-data:/root/.ollama \ + ollama/ollama + +curl -s http://localhost:11435/api/generate -d @/tmp/test_req_docker.json \ + -o /tmp/docker_resp.json \ + -w "HTTP_CODE:%{http_code} TIME:%{time_total}s\n" +``` + +**Docker(CPU) — 결과 (터미널 출력 그대로)** +``` +HTTP_CODE:200 TIME:204.825280s +done: True done_reason: stop +prompt_eval_count: 676 eval_count: 236 +total_duration(ns): 204796233095 +prompt_eval_duration(ns): 28970897000 +eval_duration(ns): 127378650000 +``` + +**네이티브(Metal) — 명령어** +```bash +# 1) 요청 전송 +curl -s http://localhost:11434/api/generate -d @/tmp/test_req_raw.json | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d.get('response')) +print('done:', d.get('done')) +" + +# 2) 서버 자체 로그에서 시간 통계 확인 +grep -an "task.n_tokens\|n_decoded\|prompt eval time\|eval time\|GIN.*api/generate" \ + /opt/homebrew/var/log/ollama.log | tail -10 +``` + +**네이티브(Metal) — 결과 (로그 파일에 찍혀있던 그대로)** +``` +task.n_tokens = 676 +prompt eval time = 3817.31 ms / 676 tokens (177.09 tokens per second) +eval time = 12169.35 ms / 216 tokens (17.75 tokens per second) +total time = 15986.66 ms / 892 tokens +[GIN] 2026/08/15 - 23:53:00 | 200 | 19.85289775s | POST "/api/generate" +``` + +두 테스트 모두 동일한 프롬프트(676 prompt 토큰), 동일한 옵션(`raw:true`, `num_predict` 계열)으로 +실행했다. + +### 벤치마크 — 정리(계산) + +| | Docker(CPU) | 네이티브(Metal) | 계산 | +|---|---|---|---| +| 총 소요 시간 | 204.825280s | 19.85289775s | 204.83 ÷ 19.85 ≈ **10.3배** | +| decode 속도 | eval_duration(127.378650s) ÷ eval_count(236) ≈ 1.85 토큰/초 | 17.75 토큰/초 (로그에 이미 계산되어 있음) | 17.75 ÷ 1.85 ≈ **9.6배** | +| 절대 시간 차이 | - | - | 204.83 − 19.85 ≈ **약 185초(3분 5초) 단축** | + +네이티브 전환 없이는 25~29초대 프론트/워커 타임아웃 제한 자체가 애초에 충족 불가능한 수준이었다. + +## 2단계 — 프롬프트 내용 버그 3건 + +| 문제 | 원인 | 해결 | +|---|---|---| +| 중국어로 답이 샘 | 한국어 강제 지시 없음 (qwen2.5는 Alibaba 모델) | `PromptBuilder` 지시문 앞부분 + 질문 바로 뒤 2곳에 "반드시 한국어로만" 추가 | +| 구체적 질문("ls 관련 명령어 찾아줘")에도 뭉뚱그린 답만 나옴 | "요약해줘/찾아줘/소개해줘" 요청을 전부 "주제만 설명"하라는 지시 하나로 처리해 구체적 대상 지정 질문까지 함께 걸림 | 지시문을 2갈래로 분리 — 구체적 키워드 지정 시 항목 빠짐없이 나열 / 대상 없는 요청은 3~4문장 요약 | +| 답변이 특정 지점에서 `done:false`로 끊김 | `/api/generate`가 기본으로 태우는 채팅 템플릿 + tool-call PEG 파서가 답변 속 백틱 코드 표기(`` `ls` ``)를 tool-call 시도로 오인 | 요청에 `raw: true` 추가해 템플릿/파서 자체를 우회 | + +## 3단계 — 시간 예산 튜닝 + +`topK`는 호출자가 1~20까지 정할 수 있는 값인데(`SearchRequest`), 검색된 후보를 그대로 프롬프트에 +다 넣고 있어 후보 수에 따라 prefill 시간이 들쭉날쭉했다. 화면 표시용 `topK`와 별개로, +`RagFacade`에서 LLM에 넘기는 후보 수를 `MAX_PROMPT_CANDIDATES = 3`으로 고정했다. + +디코드 속도 자체도 세션 중 초당 12~18토큰으로 흔들리는 것이 로그로 확인됐다(원인 미확정, 열 +스로틀링 등 추정). 이 때문에 `num_predict`/`read-timeout` 값을 여러 차례 조정했다. + +| 시점 | read-timeout | num_predict | +|---|---|---| +| 세션 시작 | 18s | (옵션 없음) | +| raw:true 적용 전 | 25s | 300 | +| ls 컷오프 대응(오진단 — 실제 원인은 raw 파서 버그) | 25s | 300 → 500 | +| 500이 자체적으로 25s 예산 초과 확인 후 | 25s | 500 → 300 | +| 후보 캡(3개) 이후에도 timeout 재현, 디코드 속도 변동 확인 | 25s → **27s** | 300 → 220 | +| 답변이 너무 짧아진다는 피드백 반영 | **27s** | 220 → 250 | +| 스트리밍 전환(6-1)으로 시간 상한을 generate-deadline이 담당 | **27s** | 250 → **400 (최종)** | + +## 4단계 — 실패 시 사용자 경험 개선 + +시간 튜닝만으로는 100% 무타임아웃을 보장할 수 없다는 결론에 따라, 실패했을 때의 경험을 +개선하는 쪽으로 방향을 잡았다. + +- **근거 문서 숨김 버그**: LLM이 "관련 문서를 찾지 못했습니다"라고 답해도 검색 후보가 그대로 + citations로 내려가 화면에 표시되던 문제. `RagFacade`에서 해당 문구 포함 시 citations를 빈 + 배열로 반환(DB에는 그대로 저장)하도록 수정. 프론트 `search-sources.ts`의 "citations 비면 + results로 대체" fallback도 제거(백엔드 수정이 프론트에서 무력화되고 있었음). +- **Extractive fallback**: Ollama 호출 실패/타임아웃 시 정적 안내 문구 대신, 최상위 검색 후보 + 원문을 최대 300자까지 인용해서 보여주도록 `RagFacade` 수정. +- **나열형 답변 압축 시도 2건 → 모두 철회**: + - "항목명: 짧은 설명 형식으로 압축" 지시 → 모델이 무시하고 기존처럼 완전한 문장으로 답함 + - "최대 8개까지만, 넘으면 '외 N개 더 있음'" 지시 → 한 케이스에서는 정확히 동작했지만, 다른 + 케이스에서 모델이 지시문을 답변에 그대로 베껴 쓰거나 스스로 없는 규칙("3개만 나열")을 + 지어내는 오작동을 유발함 + - **최종 대안**: Ollama 응답의 `eval_count`(실제 생성 토큰 수)가 `num_predict`에 도달했는지를 + 코드로 확정 판별해, 잘렸을 때만 `OllamaClient`가 답변 끝에 + `"※ 답변이 길어 일부 내용이 생략됐을 수 있습니다. 자세한 내용은 문서를 확인해주세요."`를 + 자동 첨부. LLM의 자기 판단에 의존하지 않는 방식. + +## 5단계 — KV 캐시 정밀도 문제 해결 + +~~**글자 깨짐(한자·가타카나가 한글 자리에 섞임)**: `~/Library/LaunchAgents/homebrew.mxcl.ollama.plist`에 +설정된 `OLLAMA_KV_CACHE_TYPE=q8_0`(KV 캐시 정밀도를 낮추는 옵션, 이번 세션 코드와 무관한 기존 +설정)이 유력 원인으로 지목됨. 제거하기로 합의했으나 아직 미적용.~~ → **해결됨**: plist에서 +`OLLAMA_KV_CACHE_TYPE` 항목을 제거하고 `launchctl unload`/`load`로 Ollama 서비스를 재로드했다 +(`brew services restart`는 plist를 원본으로 재생성해 수동 설정을 지우므로 쓰지 않았다). +`OLLAMA_FLASH_ATTENTION=1`은 그대로 유지. 이후 QA에서 한자/가타카나 원문이 그대로 노출되는 +사례는 재현되지 않았다 — 다만 아래 6단계에서 별도의 언어 혼입 코드 가드도 함께 추가해 이중으로 +방어한다. + +## 6단계 — 근본 원인 재규명: raw:true만으로는 부족했다 + +2단계에서 `raw:true`로 컷오프 버그를 해결했다고 판단했으나, 세션을 계속 진행하며 **같은 문서의 +같은 질문("ls 관련 명령어 찾아줘")이 매번 정확히 같은 지점("...부모")에서 반복적으로 끊기는 +현상**이 재발했다. `rag_responses.output_token_count`가 해당 응답들에서 전부 `NULL`인 것을 +확인하고, DB에 저장된 프롬프트를 그대로 재요청해 Ollama 서버 로그를 직접 대조했다. + +``` +common_chat_peg_parse: unparsed Content-only output: ... `ls ../test2`: 부모 �렉 +srv stop: cancel task +``` + +`�렉`이 결정적 단서였다 — "디렉토리"의 "디"가 토큰 경계에서 UTF-8 바이트 단위로 쪼개졌고, +`raw:true`로도 여전히 동작하던 tool-call PEG 파서가 이 불완전한 바이트열 파싱에 실패하자 +**생성 자체를 취소**했다. 이건 llama.cpp의 PEG 기반 파서가 파싱 실패 시 스트림을 통째로 +중단하는 알려진 버그 계열이며(llama.cpp #24807, #24863), Ollama 0.32.13(당시 최신)에서도 +재현됨 — 업그레이드로 해결 불가능한 서버 측 결함이다. `raw:true`는 "백틱 코드로 인한 tool-call +오인식"이라는 한 가지 촉발 경로만 막았을 뿐, "토큰 경계에서 한글이 쪼개지는" 별개의 촉발 +경로는 막지 못했던 것이다. + +이 재규명을 계기로 시간 관리와 언어 안정성을 프롬프트가 아니라 **코드 레벨에서 결정적으로 +방어**하는 쪽으로 구조를 다시 잡았다. + +### 6-1. 스트리밍 + 애플리케이션 레벨 데드라인으로 전환 + +기존에는 `stream:false`로 완성된 응답을 한 번에 기다렸고, `read-timeout`이 초과하면 **이미 생성된 +내용까지 통째로 버리고** 503(→ extractive fallback)으로 처리했다. `OllamaClient.generate()`를 +`stream:true` + NDJSON 청크 누적 방식으로 바꾸고, 청크를 하나 읽을 때마다 `ollama.generate-deadline` +(기본 25s) 초과 여부를 확인해서, 초과 시 스트림을 끊고 **그때까지 모인 부분 답변**을 문장 경계 +트리밍 + 잘림 안내로 반환한다. 한 청크도 못 받았을 때만 예외를 던져 기존 extractive fallback으로 +넘어간다. + +```java +private StreamChunks readStream(InputStream body, long deadline) throws IOException { + StringBuilder answer = new StringBuilder(); + OllamaGenerateResponse last = null; + boolean deadlineExceeded = false; + BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8)); + try { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + OllamaGenerateResponse chunk = CHUNK_MAPPER.readValue(line, OllamaGenerateResponse.class); + if (chunk.response() != null) { + answer.append(chunk.response()); + } + last = chunk; + if (chunk.done()) { + break; + } + if (System.currentTimeMillis() >= deadline) { + deadlineExceeded = true; + break; + } + } + } catch (IOException e) { + // 스트림이 멈춰 read-timeout이 본문 연결을 끊는 경우 등. 이미 받은 부분 답변이 있으면 + // 버리지 않고 done 없는 조기 종료로 처리해 반환하고, 하나도 없을 때만 실패로 전파한다. + if (answer.isEmpty()) { + throw e; + } + log.warn("Ollama 스트림 읽기 중단, 수신된 부분 답변 반환: 길이={}, 원인={}", answer.length(), e.getMessage()); + } + return new StreamChunks(answer.toString(), last, deadlineExceeded); +} +``` + +`read-timeout`(27s)의 역할도 바뀌었다 — Spring `JdkClientHttpRequestFactory`의 read-timeout은 +요청 시작부터 스트리밍 본문 수신까지 전체에 적용되므로, 스트림이 멈췄을 때의 전송 계층 최후 +방어선이 된다. **처음엔 이 경우를 못 잡아서 문제가 있었다**: `readLine()`으로 블로킹 대기 +중일 때는 데드라인을 못 보는데, 그 상태로 read-timeout이 먼저 발동해 본문 연결이 끊기면 +`IOException`이 그대로 터져서 이미 받은 부분 답변까지 통째로 버려지고 상위 extractive +fallback으로 대체되고 있었다(PR 코드리뷰로 지적됨). 위 코드처럼 읽기 루프를 `try/catch`로 +감싸 `IOException` 발생 시에도 부분 답변이 있으면 잘림으로 보존하고, 한 글자도 못 받았을 때만 +예외를 그대로 전파하도록 고쳤다. 정상 스트림의 시간 상한은 그보다 짧은 `generate-deadline`(25s)이 +먼저 담당한다. +`num_predict`는 시간 상한을 `generate-deadline`이 넘겨받은 만큼 250 → **400**으로 완화했다 +(디코드가 빠른 세션에서는 더 긴 답변을 허용). + +**PEG 파서 버그와의 관계**: 이 버그가 발생하면 최종 청크에 `done:true`가 오지 않는다 +(`common_chat_peg_parse` 실패 후 `cancel task`). 스트리밍 전환으로 이 경우도 "정상 완료가 +아닌 조기 종료"로 명시적으로 판별할 수 있게 됐다(아래 6-3). + +### 6-2. 샘플링 파라미터 추가 (temperature / top_p / repeat_penalty / repeat_last_n) + +요청 옵션에 `num_predict`만 있어 나머지는 Ollama 기본값(`temperature≈0.7`, `repeat_penalty=1.0`, +`repeat_last_n=64`)으로 돌고 있었다. RAG는 문서 내용을 그대로 답하는 용도라 창의성이 필요 없다는 +점에 착안해 4개를 추가했다. + +```java +public record OllamaGenerateOptions( + @JsonProperty("num_predict") int numPredict, + double temperature, + @JsonProperty("top_p") double topP, + @JsonProperty("repeat_penalty") double repeatPenalty, + @JsonProperty("repeat_last_n") int repeatLastN +) { +} +``` + +- `temperature: 0.3`, `top_p: 0.8` — 확률 꼬리에 있는 한자/가나 토큰이 뽑힐 확률 자체를 낮춰 + code-switching을 완화한다. +- `repeat_penalty: 1.1` — Ollama 기본값이 1.0(반복 억제 없음)임을 로그로 확인. 모델이 답을 끝내고도 + "한국어로만 답변했습니다… 번역 없음… 감사합니다…" 같은 잡담을 반복하며 토큰 상한까지 채우던 + 현상을 억제. +- `repeat_last_n: 256` — `repeat_penalty`가 되돌아보는 토큰 창. 기본 64로는 64토큰보다 긴 블록이 + 통째로 반복되는 것(디렉토리 요약이 처음부터 한 번 더 반복되는 현상)을 못 잡아서 넓혔다. 목록형 + 답변의 정당한 반복 표현(각 항목이 "~를 출력합니다"로 끝나는 등)이 어색해지면 128로 낮출 것 — + `OLLAMA_REPEAT_LAST_N` 환경변수로 코드 수정 없이 조절 가능. + +### 6-3. 언어 혼입 코드 가드 (프롬프트 지시 대신 확정적 처리) + +```java +private static SanitizedAnswer sanitizeAnswer(String text) { + // 전각 구두점은 문장 부호 역할을 유지해야 하므로 삭제하지 않고 반각으로 치환한다. + String normalized = text + .replace('。', '.').replace('、', ',').replace(':', ':') + .replace(',', ',').replace('!', '!').replace('?', '?'); + Matcher matcher = FOREIGN_CJK_PATTERN.matcher(normalized); + if (!matcher.find()) { + return new SanitizedAnswer(normalized, false); + } + int firstMixIndex = matcher.start(); + int mixedCount = matcher.group().length(); + while (matcher.find()) { + mixedCount += matcher.group().length(); + } + if (mixedCount > FOREIGN_CJK_CUT_THRESHOLD) { + log.warn("답변에 한자/가나 대량 혼입({}자) 감지, 혼입 시작 지점에서 잘라냄", mixedCount); + return new SanitizedAnswer(normalized.substring(0, firstMixIndex), true); + } + log.warn("답변에 한자/가나 혼입({}자) 감지, 제거함", mixedCount); + return new SanitizedAnswer(FOREIGN_CJK_PATTERN.matcher(normalized).replaceAll(""), false); +} +``` + +한국어 RAG 답변에 한자·히라가나·가타카나가 나올 일은 없다는 전제로, 정규식(`\p{IsHan}`, +`\p{IsHiragana}`, `\p{IsKatakana}`)으로 감지해 처리한다. 두 갈래로 나눈 이유는 실제 QA에서 +드러났다 — 짧은 낱자 혼입(8자 이하)은 단순히 지워도 문장이 자연스럽지만, **모델이 아예 중국어 +반복 루프로 넘어간 대량 혼입**을 그냥 지워버리면 "Git : 1. **Git **: - git branch: ." 같은 +구두점 뼈대만 남아 더 지저분해졌다. 그래서 8자를 넘으면 문자만 지우지 않고 **혼입이 시작된 +지점에서 답변 자체를 자르고** 문장 경계 트리밍 + 잘림 안내를 붙인다. 전각 구두점(。、:,!?)은 +삭제 대신 반각으로 치환한다 — 삭제하면 문장 부호 자체가 사라지기 때문이다. + +**`done:false` 조기 종료도 잘림으로 처리**: PEG 파서 버그(6단계 도입부)로 `done` 없이 스트림이 +끝나는 경우, 데드라인 초과와 구분해서 `log.warn`으로 빈도만 추적하고 동일하게 문장 경계 트리밍 + +잘림 안내를 적용한다. 재시도는 하지 않기로 했다 — 확률적으로 재시도하면 우회될 가능성이 높지만 +(temperature 0.3), 응답이 그만큼 느려지고 코드 분기가 늘어나는 트레이드오프가 있어 이번 라운드는 +빈도 관찰(로그)까지만 하고 보류. + +### 6-4. 문장 경계 트리밍 정교화 + +```java +private static boolean isSentenceEndDot(String text, int i) { + boolean precededOk = i == 0 + || (text.charAt(i - 1) != '.' && !Character.isDigit(text.charAt(i - 1))); + boolean followedOk = i == text.length() - 1 || text.charAt(i + 1) != '.'; + return precededOk && followedOk; +} +``` + +숫자 목록 마커("6.")와 경로 표기("..")의 마침표를 문장 끝으로 오인하지 않도록, 앞뒤에 숫자나 +마침표가 연이어 있으면 후보에서 제외한다. + +**(해결됨)** `` `ls .` `` 처럼 백틱 코드 스팬 안에 있는 마침표가 문장 끝으로 오인되던 결함은, +마침표 앞의 백틱 개수 홀짝으로 코드 스팬 내부 여부를 판별하는 `insideInlineCode` 검사를 추가해 +해결했다. 코드 스팬 내부의 마침표는 문장 경계 후보에서 제외된다. **처음엔 마침표(`.`)에만 +이 검사를 걸었는데**, 후속 QA에서 `` `taskkill -F -PID = 0) { + if (answerText.strip().startsWith(NO_RELEVANT_DOC_PHRASE)) { + return RagAnswer.of(answerText, List.of()); + } + log.warn("[RAG] 정상 답변에 무관 안내 문구 혼입, 해당 지점부터 제거: queryId={} phraseIndex={}", + queryId, phraseIndex); + answerText = answerText.substring(0, phraseIndex).strip(); +} +return RagAnswer.of(answerText, candidates); +``` + +발생 빈도는 `log.warn`으로 추적한다. 자세한 내용은 `#75` 문서 참고. + +## 7단계 — 남은 미해결 항목 + +- 위 6-4의 완결 답변에 잘림 안내가 붙는 오탐 케이스 (백틱 코드 스팬 마침표/물음표 오인식은 해결됨) +- 스프링부트(한글 음역) vs springboot(영문) 임베딩 매칭 — 조사만 하고 미해결. 현재 쿼리 + 전처리/동의어 매칭/하이브리드(BM25) 검색이 전혀 없고 순수 벡터(BGE-M3 코사인 유사도, 임계값 + 0.30)만 사용 중이라는 것까지만 확인함. +- 서버 시작 시 모델 warm-up, 더 작고 빠른 한국어 모델 비교, cold/warm p95 정식 계측(10회씩), + 임베딩 서버와의 메모리 경합 조사 — 원래 버그 리포트에 명시됐으나 이번 세션에서 미착수. +- 원래 버그 리포트의 실제 재현 케이스("코테이토 13기 회비는 얼마인가요?") 재테스트 — 미실행. +- PEG 파서 조기 종료 시 재시도(6-3에서 검토만 하고 보류) — 필요성은 빈도 로그를 보고 재판단. + +## 최종 설정값 (이 문서 작성 시점) + +- `ollama.server.connect-timeout`: 3s / `read-timeout`: 27s (본문 스트림 포함 전송 계층 최후 방어선) +- `ollama.generate-deadline`: 25s (생성 전체 시간의 실질적 상한) +- `ollama.num-predict`: 400 +- `ollama.temperature`: 0.3 / `top-p`: 0.8 +- `ollama.repeat-penalty`: 1.1 / `repeat-last-n`: 256 +- `ollama.keep-alive`: 30m +- `OllamaGenerateRequest`: `stream:true`, `raw:true` +- `RagFacade.MAX_PROMPT_CANDIDATES`: 3 +- Ollama 서비스: `OLLAMA_KV_CACHE_TYPE` 제거(정밀도 기본값 f16), `OLLAMA_FLASH_ATTENTION=1` 유지 + +closes #210 diff --git a/docs/design/kangcheolung-#65-rag-prompt-builder.md b/docs/design/kangcheolung-#65-rag-prompt-builder.md index c622e526..6c39a91d 100644 --- a/docs/design/kangcheolung-#65-rag-prompt-builder.md +++ b/docs/design/kangcheolung-#65-rag-prompt-builder.md @@ -145,86 +145,98 @@ docker compose exec ollama ollama run qwen2.5:3b "안녕" - 기본 접속 정보 http://localhost:11434, OLLAMA_SERVER_URL로 오버라이드 가능 ``` -### 5. `domain/rag/service/PromptBuilder.java` (신규) +### 5. `domain/rag/service/PromptBuilder.java` (신규, 이후 여러 이슈에 걸쳐 지속 수정됨) + +최초 구현은 명세 원문 지시문("이 내용만을 근거로 답변하고, 문서에 없는 내용은 추측하지 마세요") 하나만 +가진 단순한 형태였다. 이후 `#67`(OllamaClient 연동)에서 관찰된 실제 RAG 프롬프트 결과와 `#210`(타임아웃/ +컷오프 수정)을 거치며 아래 형태로 정착했다. **현재 코드**: ```java @Component public class PromptBuilder { + private static final int MAX_CHUNK_TEXT_CODE_POINTS = 800; + // Ollama 추론 시간은 대부분 prefill(문맥 토큰 수)에 비례한다 — 6000에서 절반으로 줄여 + // read-timeout(25s) 안에서 생성에 쓸 수 있는 여유 시간을 확보한다. + private static final int MAX_CONTEXT_TEXT_CODE_POINTS = 3_200; + private static final String INSTRUCTION = - "다음은 참고 문서입니다. 이 내용만을 근거로 답변하고,\n문서에 없는 내용은 추측하지 마세요.\n\n"; + "다음은 검색으로 찾은 참고 문서입니다. 문서 내용이 질문 주제와 실제로 관련 있는지 판단하세요.\n" + + "단순히 일부 단어가 겹친다는 이유만으로 관련 있다고 판단하지 마세요.\n" + + "문서 주제 자체가 질문과 무관하면 \"관련 문서를 찾지 못했습니다.\"라고만 답하세요.\n" + + "질문이 특정 키워드나 항목(예: 특정 명령어, 용어)을 지정해 그 내용을 찾아달라는 요청이면, " + + "문서에서 해당 부분을 찾아 관련된 항목을 빠짐없이 구체적으로 정리해서 답변하세요. " + + "이 경우 문서가 어떤 주제인지 개괄적으로만 설명하지 마세요.\n" + + "질문이 특정 항목을 지정하지 않고 문서 전체를 요약하거나 소개해달라는 요청이면(예: \"문서 찾아줘\", " + + "\"요약해줘\", \"소개해줘\"), 문서가 무엇에 대한 내용인지 3~4문장 이내로 간결하게 설명하세요.\n" + + "문서에 없는 내용은 일반 지식이나 추측으로 보완하지 마세요.\n" + + "질문이나 문서에 다른 언어가 섞여 있어도 답변은 반드시 한국어로만 작성하세요.\n\n"; public String build(String queryText, List candidates) { StringBuilder sb = new StringBuilder(INSTRUCTION); + int chunkTextLimit = chunkTextLimit(candidates.size()); // 후보가 많을수록 청크당 몫을 줄임 for (int i = 0; i < candidates.size(); i++) { - VectorSearchCandidate candidate = candidates.get(i); - sb.append(citationLine(i + 1, candidate)).append('\n'); + sb.append(citationLine(i + 1, candidates.get(i), chunkTextLimit)).append('\n'); } sb.append("\n질문: ").append(queryText); + sb.append("\n\n(다시 한번 강조: 답변은 한국어로만 작성하세요. 답변을 마쳤으면 같은 내용을 다른 언어로 " + + "번역하거나 반복해서 덧붙이지 말고 그대로 끝내세요.)"); return sb.toString(); } - private String citationLine(int order, VectorSearchCandidate candidate) { + private int chunkTextLimit(int candidateCount) { + if (candidateCount == 0) { + return MAX_CHUNK_TEXT_CODE_POINTS; + } + int sharedLimit = Math.max(1, MAX_CONTEXT_TEXT_CODE_POINTS / candidateCount); + return Math.min(MAX_CHUNK_TEXT_CODE_POINTS, sharedLimit); + } + + private String citationLine(int order, VectorSearchCandidate candidate, int chunkTextLimit) { String pageSuffix = candidate.pageNo() != null ? " p." + candidate.pageNo() : ""; - return "[%d] %s%s: \"%s\"".formatted(order, candidate.documentTitle(), pageSuffix, candidate.chunkText()); + String chunkText = truncate(candidate.chunkText(), chunkTextLimit); + return "[%d] %s%s: \"%s\"".formatted(order, candidate.documentTitle(), pageSuffix, chunkText); + } + + private String truncate(String text, int maxCodePoints) { + int codePointCount = text.codePointCount(0, text.length()); + if (codePointCount <= maxCodePoints) { + return text; + } + // 말줄임표까지 본문 예산에 포함해 전체 프롬프트 상한을 넘지 않도록 한다. + int endIndex = text.offsetByCodePoints(0, maxCodePoints - 1); + return text.substring(0, endIndex) + "…"; } } ``` -**한 줄 요약**: 검색 후보 리스트 + 질문 텍스트를 받아, 환각 방지 지시문 + 라벨링된 출처 목록 + 질문으로 이어지는 프롬프트 문자열 하나를 조립한다. +**한 줄 요약**: 검색 후보 리스트 + 질문 텍스트를 받아, 환각 방지/관련성 판단/언어 고정 지시문 + 라벨링된 +출처 목록(길이 예산 적용) + 질문 + 언어 재강조로 이어지는 프롬프트 문자열 하나를 조립한다. -- `INSTRUCTION`: 명세 원문의 지시문("이 내용만을 근거로 답변하고, 문서에 없는 내용은 추측하지 마세요")을 그대로 상수로 뺐다. RAG 구조의 환각 방지 핵심 장치이므로 문구를 임의로 바꾸지 않았다. -- 라벨(`[1]`, `[2]`...)은 `candidates` 리스트의 **인덱스 순서를 그대로 사용**한다. 이 순서는 `SearchFacade`에서 이미 유사도/live check를 거쳐 정렬된 순서(=`SearchResultItem.rank`와 동일)이므로 별도 재정렬이나 라벨 매핑 구조체가 필요 없다. 이 순서는 Issue 4(`response_citations` 저장)에서 `citation_order`/`citation_label`을 매길 때도 그대로 재사용할 계획이다. +- 라벨(`[1]`, `[2]`...)은 `candidates` 리스트의 **인덱스 순서를 그대로 사용**한다. 이 순서는 `SearchFacade`에서 이미 유사도/live check를 거쳐 정렬된 순서(=`SearchResultItem.rank`와 동일)이므로 별도 재정렬이나 라벨 매핑 구조체가 필요 없다. `response_citations` 저장(`#73`)에서 `citation_order`/`citation_label`을 매길 때도 그대로 재사용한다. - `pageSuffix`: `pageNo`가 `null`(페이지 개념이 없는 문서 포맷)이면 `" p.N"` 부분을 통째로 생략한다. - `search_results`/`document_chunks`를 다시 SELECT하지 않는다 — `VectorSearchCandidate`가 이미 `chunkText`, `documentTitle`, `pageNo`를 flat하게 갖고 있어서 이 레코드를 그대로 재사용하는 게 더 단순하고, 불필요한 재조회도 없앤다. -- 빈 리스트(`candidates.isEmpty()`)가 들어와도 이 클래스는 특별 취급하지 않는다 — 지시문 + 빈 출처 목록 + 질문으로 이어지는 프롬프트를 그대로 만든다. "검색 결과 0건이면 LLM 호출 자체를 생략한다"는 판단(NO_CONTEXT)은 이 클래스의 책임이 아니라, Issue 5에서 만들 `RagFacade`(오케스트레이션 레이어)의 책임으로 명확히 분리했다. - -> **업데이트(무관 문맥 거절 + 프롬프트 예산)**: `PromptBuilder`에 두 가지가 추가됐다. -> -> 1. **무관 문맥 거절 지시문**: `INSTRUCTION`이 "질문과 문서가 직접 관련 있는지 먼저 판단하고, 단순히 일부 단어가 겹친다는 이유만으로 관련 있다고 판단하지 말고, 충분한 근거가 없으면 '관련 문서를 찾지 못했습니다'라고만 답하라"는 문장을 포함하도록 확장됐다. 검색 유사도가 낮은 후보가 섞여 들어와도 LLM이 억지로 답변을 짜내지 않고 스스로 무관함을 판단하게 하기 위함이다. -> 2. **청크별/전체 컨텍스트 텍스트 예산(truncate)**: 청크 하나당 최대 `MAX_CHUNK_TEXT_CODE_POINTS`(800자), 전체 컨텍스트 합계 `MAX_CONTEXT_TEXT_CODE_POINTS`(6,000자) 상한이 추가됐다. 후보 개수가 많을수록 청크당 허용 길이를 균등하게 나눠 줄이고, 초과분은 말줄임표(…)로 잘라낸다. 모든 후보의 인용 라벨과 순서는 그대로 유지한 채 본문 길이만 조절한다. -> -> ```java -> private static final int MAX_CHUNK_TEXT_CODE_POINTS = 800; -> private static final int MAX_CONTEXT_TEXT_CODE_POINTS = 6_000; -> -> public String build(String queryText, List candidates) { -> StringBuilder sb = new StringBuilder(INSTRUCTION); -> int chunkTextLimit = chunkTextLimit(candidates.size()); // 후보가 많을수록 청크당 몫을 줄임 -> for (int i = 0; i < candidates.size(); i++) { -> sb.append(citationLine(i + 1, candidates.get(i), chunkTextLimit)).append('\n'); -> } -> sb.append("\n질문: ").append(queryText); -> return sb.toString(); -> } -> -> private int chunkTextLimit(int candidateCount) { -> if (candidateCount == 0) { -> return MAX_CHUNK_TEXT_CODE_POINTS; -> } -> int sharedLimit = Math.max(1, MAX_CONTEXT_TEXT_CODE_POINTS / candidateCount); -> return Math.min(MAX_CHUNK_TEXT_CODE_POINTS, sharedLimit); -> } -> -> private String truncate(String text, int maxCodePoints) { -> int codePointCount = text.codePointCount(0, text.length()); -> if (codePointCount <= maxCodePoints) { -> return text; -> } -> int endIndex = text.offsetByCodePoints(0, maxCodePoints - 1); // 말줄임표까지 예산에 포함 -> return text.substring(0, endIndex) + "…"; -> } -> ``` - -### 6. `src/test/java/.../rag/service/PromptBuilderTest.java` (신규) - -`testing_guide.md` 컨벤션(`@DisplayName` 한국어, Given/When/Then)을 따라 3개 테스트를 작성했다. +- 빈 리스트(`candidates.isEmpty()`)가 들어와도 이 클래스는 특별 취급하지 않는다 — "검색 결과 0건이면 LLM 호출 자체를 생략한다"는 판단(NO_CONTEXT)은 `RagFacade`(`#75`)의 책임으로 분리되어 있다. +- **관련성 판단 지시** (`무관 문맥 거절` — Issue 1 이후 추가): 단순 단어 겹침만으로 관련 있다고 판단하지 말고, 무관하면 `"관련 문서를 찾지 못했습니다."`라고만 답하라는 지시. 검색 유사도가 낮은 후보가 섞여 들어와도 LLM이 억지로 답변을 짜내지 않게 하기 위함. +- **컨텍스트 예산(truncate)** (Issue 1 이후 추가, `#210`에서 6,000자 → 3,200자로 재축소): 청크 하나당 `MAX_CHUNK_TEXT_CODE_POINTS`(800자), 전체 합계 `MAX_CONTEXT_TEXT_CODE_POINTS`(3,200자) 상한. 후보가 많을수록 청크당 허용 길이를 균등하게 나눠 줄이고, 초과분은 말줄임표(…)로 잘라낸다. 3,200자로 줄인 이유는 Ollama 추론 시간이 대부분 prefill(문맥 토큰 수)에 비례해서, 컨텍스트를 줄여 read-timeout 예산 안에서 생성(decode)에 쓸 시간을 더 확보하기 위함(`#210`). +- **추출형/요약형 분기** (`#210`): 질문이 특정 키워드·항목을 지정하면("ls 관련 명령어 찾아줘") 해당 항목을 빠짐없이 정리해서 답하고, 대상 없이 막연하면("요약해줘"/"소개해줘") 3~4문장으로 간결하게 답하도록 지시를 2갈래로 분리했다. 원래는 후자 하나로 뭉뚱그려 있어서, 구체적 질문에도 "이 문서는 ~에 대한 내용입니다" 식의 알맹이 없는 답이 나오는 문제가 있었다. +- **한국어 강제 지시** (`#210`, 2곳): Issue 1에서는 "안녕" 단발 호출로만 관찰됐던 한국어/중국어 혼용이, 실제 RAG 프롬프트(코드 스타일 텍스트가 섞인 문서)에서도 재현되는 것을 확인해 `INSTRUCTION` 본문과 질문 바로 뒤(생성 시작점 근처, recency 효과를 노림) 두 곳에 "반드시 한국어로만 작성하라"는 지시를 추가했다. 질문 뒤에 붙는 재강조 문구는, 답변이 끝난 뒤 모델이 같은 내용을 다른 언어로 재진술하다 끊기는 현상에 대응한 것이다. +- ~~나열형 답변 개수 제한/압축 지시~~ → **시도 후 철회 (`#210`)**: "항목당 짧은 키워드로 압축", "최대 8개까지만 나열하고 넘으면 '외 N개 더 있음'" 두 가지를 프롬프트 지시로 시도했으나, 7B 모델이 지시를 무시하거나 프롬프트 문구를 답변에 그대로 베껴 쓰는 등 오작동을 일으켜 둘 다 뺐다. 대신 "잘렸는지" 자체는 `OllamaClient`가 응답의 `eval_count`로 판별해 안내 문구를 붙이는 방식으로 옮겼다(`#67` 문서 참고). + +전체 원인 규명 과정과 실측 데이터는 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 참고. + +### 6. `src/test/java/.../rag/service/PromptBuilderTest.java` (신규, 이후 2개 테스트 추가) + +`testing_guide.md` 컨벤션(`@DisplayName` 한국어, Given/When/Then)을 따라 작성했다. 최초 3개에서, 컨텍스트 +예산(truncate) 로직 추가에 맞춰 2개가 더해져 현재 5개다. | 테스트 | 검증 내용 | |---|---| | `build_withCandidates_appendsLabeledCitations` | 후보 2개 → `[1]`, `[2]` 순서대로 라벨이 붙고, 각 문서 제목/페이지/청크 텍스트가 프롬프트에 포함되는지 | | `build_withNullPageNo_omitsPageSuffix` | `pageNo == null`인 후보는 `p.` 표기가 프롬프트에 없는지 | -| `build_alwaysIncludesInstructionAndQuestion` | 환각 방지 지시문과 `"질문: {queryText}"`가 항상 포함되는지 (빈 후보 리스트로도 검증) | +| `build_alwaysIncludesInstructionAndQuestion` | 관련성 판단/추출·요약 분기/환각 방지/한국어 강제 지시문과 `"질문: {queryText}"`가 항상 포함되는지 (빈 후보 리스트로도 검증) | +| `build_longChunk_limitsChunkTextLength` | 청크 하나가 800자를 넘으면 말줄임표(…)를 포함해 799자 + 말줄임표로 잘리는지 | +| `build_manyCandidates_sharesContextBudgetAndKeepsLabels` | 후보 20개가 들어와도 모든 라벨(`[1]`~`[20]`)이 유지되고, 전체 청크 본문 합계가 3,200자로 제한되는지 | `PromptBuilder`가 외부 의존성이 없는 순수 컴포넌트라 Mockito 없이 `new PromptBuilder()`로 바로 인스턴스화해서 테스트했다. `VectorSearchCandidate`도 `SearchFacadeTest`와 동일하게 별도 Fixture 클래스 없이 직접 `new`로 생성했다 (재사용처가 아직 이 테스트 하나뿐이라 Fixture를 만들 이유가 없음). @@ -331,17 +343,21 @@ Ollama 설치 자체가 이번 이슈 범위(docker-compose 서비스 등록)에 **언어 지시문("한국어로 답변하세요") 추가는 Issue 2로 연기** 로컬 검증 중 언어가 섞이는 현상을 관찰했지만, 실제 RAG 프롬프트(검색된 한국어 문서 chunk 포함)로 테스트해보지 않은 상태에서 미리 지시문을 추가하는 것은 검증되지 않은 변경이다. Issue 2에서 `OllamaClient`로 실제 호출해보고 문제가 재현되면 그때 `PromptBuilder.INSTRUCTION`에 한 줄 추가하기로 결정했다. +→ Issue 2(`#67`)에서는 실제로 재현되지 않아 추가 안 했으나, `#210`에서 다른 형태의 실제 RAG 프롬프트로 재현되어 결국 추가됐다(위 "5. `PromptBuilder.java`" 절 참고). --- ## 남은 이슈 / TODO ### 코드 -- `PromptBuilder`는 아직 어디에서도 호출되지 않는 독립 컴포넌트 — Issue 5에서 `RagFacade`가 실제로 연결한다. -- 언어 지시문 추가 여부는 Issue 2에서 실제 Ollama 호출 결과를 보고 판단. +- ~~`PromptBuilder`는 아직 어디에서도 호출되지 않는 독립 컴포넌트 — Issue 5에서 `RagFacade`가 실제로 연결한다.~~ → 해결됨: `#75`에서 `RagFacade.generate()`가 연결했다. 다만 `#210`에서 `RagFacade`가 `build()`에 넘기는 후보 수를 최대 3개로 제한하도록 바뀌었다(`topK`는 호출자가 1~20까지 정할 수 있어 그대로 넘기면 prefill 시간이 예측 불가능했음) — `PromptBuilder`는 여전히 "받은 candidates를 그대로 조립"만 하고 몇 개를 넘길지는 판단하지 않는다는 책임 경계는 그대로다. 자세한 내용은 `#75` 문서 참고. +- ~~언어 지시문 추가 여부는 Issue 2에서 실제 Ollama 호출 결과를 보고 판단.~~ → 해결됨: `#67`에서는 재현 안 됐으나 `#210`에서 재현되어 추가함(위 "5. `PromptBuilder.java`" 절 참고). ### 문서 - README의 기존 "Local DB" 섹션이 참조하는 `docs/local-db.md` 링크는 이번 작업 이전부터 실제 파일이 없는 broken 링크였다 — 이번 이슈 범위 밖이라 별도로 손대지 않음. ### 다음 단계 -Issue 2 — `OllamaServerConfig`(RestClient Bean) + `OllamaClient` 구현. `PromptBuilder.build()`로 만든 프롬프트를 `POST /api/generate`로 실제 전송하고, 타임아웃/장애 시 503 `SERVICE_UNAVAILABLE`로 처리하는 예외 처리까지 포함한다. +~~Issue 2 — `OllamaServerConfig`(RestClient Bean) + `OllamaClient` 구현. `PromptBuilder.build()`로 만든 프롬프트를 `POST /api/generate`로 실제 전송하고, 타임아웃/장애 시 503 `SERVICE_UNAVAILABLE`로 처리하는 예외 처리까지 포함한다.~~ → 완료됨(`#67`). 이후 `#75`/`#210`에서 Ollama 실패 시 503을 그대로 반환하는 대신 `RagFacade`가 최상위 검색 후보 원문을 인용하는 extractive fallback을 반환하는 방식으로 바뀌었다. + +`OllamaClient` 쪽 변경(raw:true, num_predict, 잘림 감지)은 `#67` 문서, RAG 타임아웃 수정 전체 배경은 +`docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 참고. diff --git a/docs/design/kangcheolung-#67-ollama-client.md b/docs/design/kangcheolung-#67-ollama-client.md index 913de323..19eefef1 100644 --- a/docs/design/kangcheolung-#67-ollama-client.md +++ b/docs/design/kangcheolung-#67-ollama-client.md @@ -91,19 +91,26 @@ ollama: > **주의**: "재배포 없이 교체 가능"은 코드 변경/재빌드가 필요 없다는 뜻이지, 무중단으로 자동 전환된다는 뜻은 아니다. `OllamaClient`가 `model`을 생성자 주입(`@Value("${ollama.model}")`)으로 받기 때문에, 이미 떠 있는 프로세스는 `OLLAMA_MODEL` 값이 바뀌어도 그 값을 다시 읽지 않는다. 실제로 교체하려면 ① 새 모델을 `ollama pull`로 미리 받아두고 ② 애플리케이션을 재시작해야 한다. -### 3. `global/config/OllamaServerConfig.java` (신규) +### 3. `global/config/OllamaServerConfig.java` (신규, `connect-timeout`/`read-timeout` 기본값 3차례 조정됨) +**현재 코드**: ```java +/** + * Ollama HTTP 연결과 추론 응답 제한 시간을 실행 환경별로 구성한다. + * + *

RAG 도메인은 Timeout 이후의 검색 결과 Fallback을 책임지고, 이 설정은 프론트의 29초 검색 제한과 + * Sites의 30초 요청 제한보다 먼저 호출을 종료할 수 있는 Transport 경계만 책임진다.

+ */ @Configuration public class OllamaServerConfig { @Value("${ollama.server.base-url}") private String baseUrl; - @Value("${ollama.server.connect-timeout:5s}") + @Value("${ollama.server.connect-timeout:3s}") private Duration connectTimeout; - @Value("${ollama.server.read-timeout:20s}") + @Value("${ollama.server.read-timeout:18s}") private Duration readTimeout; @Bean("ollamaRestClient") @@ -121,45 +128,52 @@ public class OllamaServerConfig { } } ``` +`application.yml`의 실제 기본값은 `connect-timeout: 3s`, `read-timeout: 27s`다(`${OLLAMA_SERVER_READ_TIMEOUT:27s}`) — 위 `@Value`의 인라인 기본값(`18s`)은 `application.yml`이 항상 값을 제공하므로 실행 시 도달하지 않는, 갱신되지 않은 fallback이다. **한 줄 요약**: `EmbeddingServerConfig`와 완전히 동일한 구조로, Ollama 전용 `RestClient` Bean을 하나 등록한다. -- `connectTimeout`(기본 5초): 로컬 docker 컨테이너라 연결 자체는 임베딩 서버와 마찬가지로 빨리 되거나 안 되거나이므로 5초로 동일하게 뒀다. -- `readTimeout`(기본 20초): 임베딩 서버(5초)보다 4배 길게 잡았다. 벡터 변환은 순간적으로 끝나지만, LLM이 텍스트를 토큰 단위로 하나씩 생성하는 건 본질적으로 훨씬 오래 걸린다. 명세의 NFR("전체 응답 5초 이내")은 목표치이지 하드 타임아웃이 아니라서, 너무 짧게 잡아 정상적으로 생성 중인 요청을 조기에 503으로 끊어버리는 걸 피하고자 여유 있게 잡았다. -- `@Value("${ollama.server.connect-timeout:5s}")`/`read-timeout`: 값을 코드에 하드코딩하지 않고 `application.yml`(`OLLAMA_SERVER_CONNECT_TIMEOUT`/`OLLAMA_SERVER_READ_TIMEOUT`)로 외부화했다 — Sites Worker의 30초 요청 제한보다 먼저 종료해 검색 결과 Fallback을 반환해야 한다는 요구가 후속 이슈에서 추가되며, 환경별로 값을 조정할 수 있게 바뀌었다. - `@Bean("ollamaRestClient")`: 임베딩용 `RestClient`와 이름으로 구분해서, `OllamaClient`가 `@Qualifier`로 정확히 이 Bean만 주입받게 한다. +- **타임아웃 값 변천**: 최초 `connect-timeout: 5s`, `read-timeout: 20s`(임베딩 서버 5s의 4배 — LLM 생성이 벡터 변환보다 본질적으로 오래 걸림) → 프론트 29초/Sites Worker 30초 요청 제한이 추가되며 `3s`/`18s`로 축소(그 제한들보다 먼저 종료해 fallback을 반환하기 위함) → `#210`에서 `read-timeout`이 다시 **27s**로 상향. Ollama를 Docker(CPU 전용)에서 macOS 네이티브(Metal 가속)로 옮기면서 18s는 오히려 부족한 값이 됐고("18초"는 Docker/CPU 기준 산정값이었음), 실측 결과 디코드 속도가 세션 중 초당 12~18토큰으로 흔들리는 것이 확인돼 27s까지 올렸다(프론트 29초 제한보다는 여전히 확실히 작음). Docker vs 네이티브 실측 벤치마크와 시간 예산 조정의 전체 히스토리는 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 참고. -> **업데이트(RAG 프롬프트 예산과 타임아웃 정렬)**: `connect-timeout`/`read-timeout` 기본값이 각각 5s/20s → 3s/18s로 줄었다. 프론트엔드에 29초 검색 요청 제한이 추가되면서, 기존 Sites Worker 30초 제한뿐 아니라 그보다 짧은 프론트 제한 안에서도 먼저 안전하게 끊고 fallback을 반환하도록 재조정한 것이다. -> -> ```java -> @Value("${ollama.server.connect-timeout:3s}") -> private Duration connectTimeout; -> -> @Value("${ollama.server.read-timeout:18s}") -> private Duration readTimeout; -> ``` -> -> `application.yml` 기본값과 주석도 동일하게 갱신됐다. -> ```yaml -> ollama: -> server: -> # 프론트의 29초 및 Sites Worker의 30초 제한 전에 검색 결과 Fallback을 반환한다. -> connect-timeout: ${OLLAMA_SERVER_CONNECT_TIMEOUT:3s} -> read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:18s} -> ``` - -### 4. DTO 3종 (신규) +### 4. DTO 3종 (신규, 이후 `OllamaGenerateRequest`에 필드 다수 추가됨) -**`domain/rag/dto/request/OllamaGenerateRequest.java`** — 우리가 Ollama에 보내는 요청 +**`domain/rag/dto/request/OllamaGenerateRequest.java`** — 우리가 Ollama에 보내는 요청. **현재 코드**: ```java -public record OllamaGenerateRequest(String model, String prompt, boolean stream) { +public record OllamaGenerateRequest( + String model, + String prompt, + boolean stream, + // 채팅 템플릿(및 그에 딸린 tool-call PEG 파서)을 거치지 않고 프롬프트를 그대로 전달한다. + // 템플릿을 타면 답변에 섞인 백틱(`ls` 등) 코드 표기를 모델이 tool-call 시도로 오인해 + // 생성이 done:false로 중간에 끊기는 문제가 있었다. + boolean raw, + @JsonProperty("keep_alive") String keepAlive, + OllamaGenerateOptions options +) { + public record OllamaGenerateOptions( + @JsonProperty("num_predict") int numPredict, + double temperature, + @JsonProperty("top_p") double topP, + @JsonProperty("repeat_penalty") double repeatPenalty, + @JsonProperty("repeat_last_n") int repeatLastN + ) { + } } ``` -Ollama `/api/generate`가 요구하는 요청 body 그대로다. `stream`은 항상 `false`로 고정해서 호출한다 — 답변을 토큰 단위로 실시간 스트리밍 받는 대신, 완성된 답변을 한 번에 받는다. RAG 명세 11장("실시간 스트리밍 응답은 1단계 제외 범위")과 일치하는 선택이고, 스트리밍을 받으면 우리 쪽에서 조각난 응답을 다시 이어붙이는 로직이 추가로 필요해지는데 지금 필요 없는 복잡도다. +`stream`은 최초 구현엔 `false`로 고정했다(RAG 명세 11장 "실시간 스트리밍 응답은 1단계 제외 범위"와 +일치하는 선택). `#210`에서 `true`로 전환했다 — 이유는 아래 "5. `OllamaClient.java`" 절 참고. -**`domain/rag/dto/response/OllamaGenerateResponse.java`** — Ollama가 주는 원본 응답 +`raw`/`keepAlive`/`options`는 최초 구현엔 없던 필드다(`#210`에서 추가, 원인 규명 과정과 실측 데이터는 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 참고). +- `raw: true` — `/api/generate`가 기본으로 태우는 채팅 템플릿과 tool-call용 PEG 파서가, 답변 속 백틱 코드 표기(예: `` `ls` ``)를 tool-call 시도로 오인해서 생성을 `done:false`로 중간에 끊어버리는 버그를 직접 curl로 재현해서 찾아냈다. **다만 이걸로 완전히 해결된 건 아니었다** — 별개로, 한글이 토큰 경계에서 UTF-8 바이트 단위로 쪼개질 때 이 PEG 파서가 파싱 실패로 생성을 취소하는 알려진 llama.cpp 버그(#24807, #24863)가 남아있어, `raw:true` 이후에도 같은 문서/질문이 반복적으로 특정 지점에서 끊기는 현상이 재발했다. 자세한 재규명 과정은 `#210` 문서 6단계 참고. +- `keep_alive: "30m"`(`application.yml`의 `ollama.keep-alive`) — 요청 사이 모델을 GPU 메모리에 상주시켜, 매 요청마다 발생하던 콜드 로딩 비용(9~11초)을 없앤다. +- `options.num_predict` — 생성 토큰 상한. 초기 300 → 500(컷오프 대응, 오진단) → 300 → 220 → 250 → 스트리밍 전환 후 시간 상한을 `generate-deadline`이 넘겨받으면서 **400(최종)**으로 완화. 자세한 변천 과정은 `#210` 문서 3단계 표 참고. +- `options.temperature`(0.3) / `top_p`(0.8) — Ollama 기본값(temperature≈0.7)이 확률 꼬리의 한자/가나 토큰을 뽑을 여지를 키운다고 보고 낮췄다(`#210`). +- `options.repeat_penalty`(1.1) / `repeat_last_n`(256) — Ollama 기본값이 각각 1.0(반복 억제 없음)/64(짧은 창)로 확인됨. 모델이 답을 끝내고도 잡담을 반복하거나, 64토큰보다 긴 블록을 통째로 반복하는 현상을 억제하기 위해 추가(`#210`). + +**`domain/rag/dto/response/OllamaGenerateResponse.java`** — Ollama가 주는 원본 응답. **현재 코드**: ```java public record OllamaGenerateResponse( + String model, String response, boolean done, @JsonProperty("prompt_eval_count") Integer promptEvalCount, @@ -167,11 +181,12 @@ public record OllamaGenerateResponse( ) { } ``` -Ollama는 실제로는 `total_duration`, `context`(토큰 ID 배열) 등 훨씬 많은 필드를 돌려주는데, 우리가 실제로 쓰는 4개만 뽑아서 받는다. `@JsonProperty("prompt_eval_count")`는 "JSON 필드명은 snake_case(`prompt_eval_count`)로 오지만 자바 필드는 camelCase(`promptEvalCount`)로 매핑해라"는 Jackson 지시다. 로컬 Ollama에 curl로 실제 호출해서 이 필드명들이 정확히 일치하는 것을 확인했다(아래 "로컬 검증" 참고). +Ollama는 실제로는 `total_duration`, `context`(토큰 ID 배열) 등 훨씬 많은 필드를 돌려주는데, 우리가 실제로 쓰는 필드만 뽑아서 받는다. `@JsonProperty("prompt_eval_count")`는 "JSON 필드명은 snake_case(`prompt_eval_count`)로 오지만 자바 필드는 camelCase(`promptEvalCount`)로 매핑해라"는 Jackson 지시다. 로컬 Ollama에 curl로 실제 호출해서 이 필드명들이 정확히 일치하는 것을 확인했다(아래 "로컬 검증" 참고). `model` 필드는 최초 구현엔 없었으나 이후 추가됐다(정확한 시점 미상 — `#210` 범위는 아님). -**`domain/rag/dto/OllamaGenerateResult.java`** — `OllamaClient`가 최종적으로 반환하는 결과 +**`domain/rag/dto/OllamaGenerateResult.java`** — `OllamaClient`가 최종적으로 반환하는 결과. **현재 코드**: ```java public record OllamaGenerateResult( + String model, // 실제 응답을 생성한 모델명 (Ollama 응답의 model 필드) String answerText, // Ollama가 생성한 답변 문장 (Ollama 응답의 response 필드) Integer inputTokenCount, // 프롬프트(지시문+출처+질문)가 소비한 토큰 수 (Ollama 응답의 prompt_eval_count) Integer outputTokenCount, // 생성된 답변이 소비한 토큰 수 (Ollama 응답의 eval_count) @@ -181,70 +196,160 @@ public record OllamaGenerateResult( ``` `OllamaGenerateResponse`(Ollama 응답 형식에 종속)와 `OllamaGenerateResult`(우리 서비스가 실제로 쓰는 값)를 굳이 두 단계로 나눈 이유는, `QueryEmbeddingService`가 `EmbedResult`를 반환하는 것과 같은 이유다 — 나중에 Ollama 응답 형식이 바뀌거나 다른 LLM 서버로 갈아타도, `OllamaClient`를 호출하는 쪽(Issue 3, 5)은 `OllamaGenerateResult`만 알면 되고 영향을 안 받는다. 또한 `RagResponse` 엔티티(Issue 1 이전부터 존재)에 이미 `inputTokenCount`/`outputTokenCount`/`latencyMs` 컬럼이 있어서, Ollama 응답에 마침 포함돼 있던 토큰 수(`prompt_eval_count`, `eval_count`)를 버리지 않고 여기 담아 Issue 3이 그대로 저장할 수 있게 했다. `latencyMs`는 Ollama가 주는 값을 쓰지 않고 `OllamaClient`가 호출 앞뒤로 직접 `System.currentTimeMillis()`를 재서 계산한다 — 순수 모델 연산 시간이 아니라 네트워크 왕복까지 포함한 "사용자가 실제로 기다린 시간"이 명세 NFR의 의도와 맞기 때문이다. -### 5. `domain/rag/service/OllamaClient.java` (신규 + 코드리뷰 반영) +### 5. `domain/rag/service/OllamaClient.java` (신규 + 코드리뷰 반영; `#210`에서 raw/샘플링 옵션 추가 후, 같은 이슈 내에서 스트리밍+데드라인 방식으로 재작성) + +최초 구현은 `stream:false`로 완성된 응답을 한 번에 기다리다 실패하면 즉시 503을 던지는 동기 호출이었다. +`#210` 진행 중 이 방식의 한계(전체 응답에 걸리는 read-timeout을 넘기면 이미 생성된 내용까지 통째로 +버려짐)가 드러나 `stream:true` + NDJSON 청크 누적 + 애플리케이션 레벨 데드라인 방식으로 재작성했다. +전체 배경은 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 6단계 참고. **현재 코드**: ```java @Slf4j @Service public class OllamaClient { + private static final String TRUNCATION_NOTICE = + "\n\n(※ 답변이 길어 일부 내용이 생략됐을 수 있습니다. 자세한 내용은 문서를 확인해주세요.)"; + + private static final ObjectMapper CHUNK_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private static final Pattern FOREIGN_CJK_PATTERN = + Pattern.compile("[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}]+"); + private static final int FOREIGN_CJK_CUT_THRESHOLD = 8; + private final String model; + private final String keepAlive; + private final int numPredict; + private final double temperature; + private final double topP; + private final double repeatPenalty; + private final int repeatLastN; + private final Duration generateDeadline; private final RestClient restClient; public OllamaClient( @Value("${ollama.model}") String model, + @Value("${ollama.keep-alive}") String keepAlive, + @Value("${ollama.num-predict}") int numPredict, + @Value("${ollama.temperature}") double temperature, + @Value("${ollama.top-p}") double topP, + @Value("${ollama.repeat-penalty}") double repeatPenalty, + @Value("${ollama.repeat-last-n}") int repeatLastN, + @Value("${ollama.generate-deadline}") Duration generateDeadline, @Qualifier("ollamaRestClient") RestClient restClient ) { this.model = model; + this.keepAlive = keepAlive; + this.numPredict = numPredict; + this.temperature = temperature; + this.topP = topP; + this.repeatPenalty = repeatPenalty; + this.repeatLastN = repeatLastN; + this.generateDeadline = generateDeadline; this.restClient = restClient; } public OllamaGenerateResult generate(String prompt) { long start = System.currentTimeMillis(); + long deadline = start + generateDeadline.toMillis(); - OllamaGenerateResponse response; + StreamChunks chunks; try { - response = restClient.post() + chunks = restClient.post() .uri("/api/generate") - .body(new OllamaGenerateRequest(model, prompt, false)) - .retrieve() - .body(OllamaGenerateResponse.class); + .body(new OllamaGenerateRequest( + model, prompt, true, true, keepAlive, + new OllamaGenerateOptions(numPredict, temperature, topP, repeatPenalty, repeatLastN) + )) + .exchange((request, response) -> { + if (response.getStatusCode().isError()) { + log.error("Ollama 서버 오류 응답: status={}", response.getStatusCode()); + throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); + } + return readStream(response.getBody(), deadline); + }); } catch (RestClientException e) { log.error("Ollama 서버 호출 실패: {}", e.getMessage()); throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); } - if (response == null || response.response() == null) { - log.error("Ollama 응답이 비어있음: response={}", response); + // 한 토큰도 못 받았으면 부분 답변 반환 대신 예외를 던져 상위의 extractive fallback에 맡긴다. + if (chunks.last() == null || chunks.answer().isBlank()) { + log.error("Ollama 스트리밍 응답에서 답변을 받지 못함: deadlineExceeded={}", chunks.deadlineExceeded()); + throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); + } + + boolean prematureEnd = !chunks.last().done(); + if (prematureEnd && !chunks.deadlineExceeded()) { + log.warn("Ollama 스트림이 done 없이 조기 종료됨(서버 측 생성 취소 추정): 수신 텍스트 길이={}", chunks.answer().length()); + } + + SanitizedAnswer sanitized = sanitizeAnswer(chunks.answer()); + if (sanitized.text().isBlank()) { + log.error("한자/가나 혼입 처리 후 답변이 비어 있음"); throw new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE); } + String answerText = sanitized.text(); + boolean hitTokenLimit = chunks.last().evalCount() != null && chunks.last().evalCount() >= numPredict; + if (hitTokenLimit || prematureEnd || sanitized.cutAtMixing()) { + answerText = trimToSentenceBoundary(answerText) + TRUNCATION_NOTICE; + } + int latencyMs = (int) (System.currentTimeMillis() - start); return new OllamaGenerateResult( - response.response(), response.promptEvalCount(), response.evalCount(), latencyMs + chunks.last().model(), answerText, chunks.last().promptEvalCount(), chunks.last().evalCount(), latencyMs ); } + + private StreamChunks readStream(InputStream body, long deadline) throws IOException { /* NDJSON 라인 단위로 읽어 응답을 누적, 데드라인 초과 시 중단 */ } + + private static SanitizedAnswer sanitizeAnswer(String text) { /* 한자/가나 낱자는 제거, 8자 초과 대량 혼입은 시작 지점에서 컷 */ } + + private static String trimToSentenceBoundary(String text) { /* 숫자 목록·경로(..) 마침표를 문장 끝으로 오인하지 않고 마지막 완결 문장까지만 남김 */ } } ``` -**한 줄 요약**: 프롬프트 문자열 하나를 받아 Ollama에 전송하고, 성공하면 답변/토큰수/latency를 담은 결과를, 실패하면 예외를 던지는 얇은 HTTP 클라이언트. +**한 줄 요약**: 프롬프트 문자열 하나를 받아 Ollama에 스트리밍으로 전송하고, 청크를 누적하며 데드라인을 감시하다가 성공하면 언어 혼입 제거·잘림 처리를 거친 답변/토큰수/latency를 담은 결과를, 실패하면 예외를 던지는 HTTP 클라이언트. -- **①시작 시각 기록 → ②요청 조립·전송 → ③실패 시 503 변환 → ④빈 응답 방어 → ⑤latency 계산 후 결과 포장** 순서로 진행된다. -- `model`/`restClient`는 생성자 주입이다. `model`은 `@Value("${ollama.model}")`로 설정값을, `restClient`는 `@Qualifier("ollamaRestClient")`로 3번에서 만든 그 Bean만 정확히 받는다. -- `catch (RestClientException e)`: 타임아웃, 연결 거부(Ollama 컨테이너가 안 떠 있는 경우) 등 HTTP 레벨 실패를 전부 포괄한다. `QueryEmbeddingService.embed()`의 catch 블록과 동일한 패턴. -- **`if (response == null || response.response() == null)` — 코드리뷰(CodeRabbit)로 추가된 방어 로직.** 처음 구현했을 때는 이 체크가 없었는데, `RestClient`가 빈 body를 받으면 `retrieve().body(...)`가 예외를 던지지 않고 `null`을 반환할 수 있고, 그러면 바로 다음 줄의 `response.response()`에서 `NullPointerException`이 그대로 튀어나가 503이 아니라 처리되지 않은 500으로 응답될 위험이 있었다. `QueryEmbeddingService`가 임베딩 벡터에 대해 이미 하고 있는 null 체크(`response == null || response.vector() == null`)와 동일한 패턴을 그대로 가져왔다. 자세한 내용은 "코드리뷰 반영" 절 참고. +- `model`/`restClient`는 최초 구현부터 생성자 주입. `#210`에서 `keepAlive`/`numPredict`, 이어서 `temperature`/`topP`/`repeatPenalty`/`repeatLastN`/`generateDeadline`이 같은 방식(`@Value`)으로 추가됐다. +- `catch (RestClientException e)`: 타임아웃, 연결 거부 등 HTTP 레벨 실패를 전부 포괄한다. `QueryEmbeddingService.embed()`의 catch 블록과 동일한 패턴. +- **응답이 없거나(`chunks.last() == null`) 답변이 비었으면(`chunks.answer().isBlank()`)** 503으로 처리 — 최초 구현의 "빈 응답 방어"(코드리뷰 반영) 취지를 스트리밍 구조에 맞게 이어받은 것이다. - NO_CONTEXT(검색 결과 0건일 때 호출 생략) 판단 로직은 여기 없다 — 이 메서드는 항상 받은 프롬프트를 그대로 보낸다. - -### 6. `src/test/java/.../rag/service/OllamaClientTest.java` (신규) - -`QueryEmbeddingServiceTest`와 동일한 Mockito 패턴 — `RestClient.post()` → `RequestBodyUriSpec`(`Answers.RETURNS_SELF`로 메서드 체이닝을 그대로 흉내) → `retrieve()` → `ResponseSpec.body(...)`를 mocking한다. +- **`raw: true`** — `/api/generate`가 기본으로 태우는 채팅 템플릿과 tool-call용 PEG 파서가 답변 속 백틱 코드를 tool-call 시도로 오인해 생성을 끊는 버그를 우회한다. **다만 완전한 해결책은 아니었다** — 한글이 토큰 경계에서 바이트 단위로 쪼개질 때 같은 파서가 파싱 실패로 생성을 취소하는 별개의 llama.cpp 버그(#24807, #24863)가 남아있다. +- **`stream:true` + `readStream()` + `generate-deadline`** (`#210`) — 기존 "전체 응답에 read-timeout, 초과 시 통째로 버림" 방식을, "청크 단위로 누적하며 데드라인 감시, 초과 시 그때까지 받은 부분 답변 반환"으로 바꿨다. `read-timeout`(27s)은 요청 시작부터 본문 스트림까지 전체에 적용되는 전송 계층 최후 방어선으로 남고(스트림이 멈춰 이 타임아웃이 발동해도 읽기 중 IOException을 잡아 이미 받은 부분 답변은 잘림으로 보존), 정상 스트림의 시간 상한은 그보다 짧은 `generate-deadline`(25s)이 먼저 담당한다. +- **`prematureEnd`(`done:true` 없이 스트림 종료)** (`#210`) — 데드라인 초과와는 별개로, 위 PEG 파서 버그가 발생하면 최종 청크에 `done:true`가 오지 않는다. 이 경우도 잘림으로 간주해 트리밍+안내 문구를 붙이고, 데드라인 초과가 아닌 조기 종료는 `log.warn`으로 빈도를 추적한다(재시도는 검토만 하고 보류 — `#210` 문서 7단계). +- **`sanitizeAnswer()` — 언어 혼입 코드 가드** (`#210`) — 한국어 RAG 답변에 한자·히라가나·가타카나가 나올 일은 없다는 전제로 정규식 감지. 8자 이하 낱자 혼입은 문자만 제거하고, 8자를 초과하는 대량 혼입(모델이 중국어 반복 루프로 넘어간 경우)은 문자만 지우면 구두점 뼈대가 지저분하게 남아서 **혼입이 시작된 지점에서 답변 자체를 자른다**. 전각 구두점(。、:,!?)은 삭제 대신 반각으로 치환. +- **`trimToSentenceBoundary()` 정교화** (`#210`) — 숫자 목록 마커("6.")와 경로 표기("..")의 마침표를 문장 끝으로 오인하지 않도록 전후 문자를 검사하고, `` `ls .` ``처럼 백틱 코드 스팬 안의 문장 부호는 앞쪽 백틱 개수 홀짝 판별(`insideInlineCode`)로 제외한다. 처음엔 마침표(`.`)에만 이 검사를 걸었는데, QA에서 `` `taskkill -F -PID = num_predict`) 외에 `prematureEnd`, `sanitized.cutAtMixing()`도 트리밍+안내 문구를 트리거한다 — LLM의 자기 판단에 의존하지 않고 코드로 확정 판별한다는 원칙은 그대로 유지된다. +- **스트림 정지 시 부분 답변 보존** (`#210`, PR 코드리뷰 반영) — `readStream()`이 `readLine()`으로 블로킹 대기 중일 때는 데드라인을 못 보므로, 스트림이 멈춘 채 `read-timeout`(27s)이 먼저 발동해 본문 연결이 끊기면 `IOException`이 발생한다. 이걸 잡지 않으면 이미 받은 부분 답변까지 통째로 버려지고 상위(`RagFacade`)의 extractive fallback으로 대체됐다. 읽기 루프를 `try/catch`로 감싸 `IOException` 발생 시에도 이미 받은 텍스트가 있으면 잘림(트리밍+안내 문구)으로 반환하고, 한 글자도 못 받았을 때만 예외를 그대로 전파한다. +- **답변 중간에 섞인 "관련 문서를 찾지 못했습니다" 문구 처리는 `OllamaClient`가 아니라 `RagFacade`의 책임**이다 (`#210`) — 자세한 내용은 `#75` 문서 참고. + +### 6. `src/test/java/.../rag/service/OllamaClientTest.java` (신규, 이후 스트리밍 구조로 재작성되며 15개로 확장) + +Mockito 패턴이 스트리밍 구조에 맞춰 바뀌었다 — 최초 구현은 `RestClient.post()` → `retrieve()` → +`ResponseSpec.body(...)`를 mocking했으나, `exchange()` 기반으로 바뀌면서 `givenStreamBody(String ndjson)` +헬퍼가 `ExchangeFunction`을 가로채 주어진 NDJSON 문자열을 `InputStream`으로 흘려보내는 방식으로 +교체됐다. | 테스트 | 검증 내용 | |---|---| -| `generate_success` | 정상 응답 시 `answerText`/`inputTokenCount`/`outputTokenCount`가 그대로 담기고 `latencyMs >= 0`인지 | -| `generate_serverUnavailable_throwsException` | `ResourceAccessException`(RestClientException의 하위 타입) 발생 시 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 | -| `generate_nullResponse_throwsException` | `body(...)`가 `null`을 반환할 때 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 (코드리뷰 반영) | -| `generate_nullAnswerText_throwsException` | 응답 객체는 있지만 `response` 필드가 `null`일 때 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 (코드리뷰 반영) | +| `generate_success` | NDJSON 청크를 누적해 `answerText`/`model`/토큰수/`latencyMs`가 올바르게 조립되는지 | +| `generate_hitsNumPredict_appendsTruncationNotice` | `eval_count`가 `num_predict` 이상이면 답변 끝에 잘림 안내 문구가 붙는지 | +| `generate_hitsNumPredict_trimsToLastSentence` | 토큰 상한 도달 시 마지막 완결 문장까지만 남기고 트리밍되는지 | +| `generate_deadlineExceeded_returnsPartialAnswer` | 데드라인 초과 시 스트림을 중단하고 그때까지 받은 부분 답변에 안내 문구를 붙이는지 | +| `generate_prematureStreamEnd_treatsAsTruncation` | `done:true` 없이 스트림이 끝나면(PEG 파서 버그 재현) 잘림으로 처리되는지 | +| `generate_streamStalled_returnsPartialAnswer` | 스트림 읽기 중 `IOException`이 나도 이미 받은 부분 답변을 잘림으로 반환하는지 (PR 코드리뷰 반영) | +| `generate_trims_ignoresDotInsideInlineCode` | 백틱 코드 스팬(`` `ls .` ``) 안의 마침표를 문장 끝으로 오인하지 않는지 | +| `generate_stripsForeignCjkCharacters` | 낱자 수준(8자 이하) 한자/가나 혼입을 제거하고 한국어만 남기는지 | +| `generate_trims_ignoresQuestionMarkInsideInlineCode` | 백틱 코드 스팬 안의 물음표를 문장 끝으로 오인하지 않는지 | +| `generate_heavyCjkMixing_cutsAtMixingPoint` | 8자를 초과하는 대량 혼입은 혼입 시작 지점에서 잘라내는지 | +| `generate_trims_ignoresConsecutiveDots` | 경로 표기(`..`)의 연속 마침표를 문장 끝으로 오인하지 않는지 | +| `generate_serverUnavailable_throwsException` | `ResourceAccessException` 발생 시 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 | +| `generate_emptyStream_throwsException` | 스트림에서 청크를 하나도 못 받으면 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 (코드리뷰 반영 취지 계승) | +| `generate_blankAnswer_throwsException` | 답변 텍스트 없이 `done`만 오면 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 (코드리뷰 반영 취지 계승) | +| `generate_errorStatus_throwsException` | Ollama가 5xx를 반환하면 `RAG_SERVICE_UNAVAILABLE` 예외로 변환되는지 | --- @@ -390,8 +495,8 @@ PR에 자동 코드리뷰 코멘트 2건이 달렸고, 각각 다음과 같이 **기존 임베딩 서버 연동 패턴을 그대로 재사용** `RestClient` Bean 분리 + 얇은 서비스가 `RestClientException`을 도메인 예외로 변환하는 구조를, 새로 고안하지 않고 `EmbeddingServerConfig`/`QueryEmbeddingService`에서 그대로 가져왔다. 같은 유형의 문제(로컬 사이드카 HTTP 호출)에 다른 해법을 쓸 이유가 없었다. -**readTimeout을 임베딩 서버보다 길게(5초 → 기본 20초, 설정으로 조정 가능)** -LLM 텍스트 생성은 벡터 변환과 걸리는 시간의 성격이 다르다. NFR의 "5초 이내"는 목표치이지 하드 타임아웃이 아니므로, 짧은 타임아웃으로 정상 생성 중인 요청을 조기에 끊는 것을 피했다. 이후 Sites Worker의 30초 요청 제한보다 먼저 종료해 검색 결과 Fallback을 반환해야 한다는 요구가 추가되며 하드코딩 값이 `OLLAMA_SERVER_READ_TIMEOUT` 설정값으로 외부화됐다(위 "3. `OllamaServerConfig.java`" 절 참고). +**readTimeout을 임베딩 서버보다 길게(설정으로 조정 가능, 현재 기본 27초)** +LLM 텍스트 생성은 벡터 변환과 걸리는 시간의 성격이 다르다. NFR의 "5초 이내"는 목표치이지 하드 타임아웃이 아니므로, 짧은 타임아웃으로 정상 생성 중인 요청을 조기에 끊는 것을 피했다. 값 자체는 5s→20s(최초)→3s/18s(프론트/Worker 제한 대응)→3s/**27s**(`#210`, 네이티브 전환 후 디코드 속도 실측 반영)로 여러 차례 조정됐다 — 변천 과정은 위 "3. `OllamaServerConfig.java`" 절 참고. **모델명을 설정값으로 외부화** `qwen2.5:3b` → `7b` 같은 향후 교체 시나리오(명세 0.3)에 대비해, `OllamaClient` 코드에는 모델명을 전혀 하드코딩하지 않았다. `application.yml`의 `ollama.model` 값만 바꾸면 재배포 없이(환경변수 재주입만으로) 교체 가능하다. @@ -416,9 +521,15 @@ Qwen2.5 `3b`가 Apache 2.0이 아니라 비상업 연구용 "Qwen Research Licen ## 남은 이슈 / TODO ### 코드 -- `OllamaClient`는 아직 어디에서도 호출되지 않는 독립 컴포넌트 — Issue 5에서 `RagFacade`가 실제로 연결한다. +- ~~`OllamaClient`는 아직 어디에서도 호출되지 않는 독립 컴포넌트 — Issue 5에서 `RagFacade`가 실제로 연결한다.~~ → 해결됨: `#75`에서 `RagFacade.generate()`가 연결했다. 검색 후보 개수 제한(`MAX_PROMPT_CANDIDATES`), citations 숨김, extractive fallback은 `OllamaClient`가 아니라 `RagFacade`(`#75` 문서)에서 처리한다 — `OllamaClient`는 여전히 "받은 프롬프트를 그대로 전송하는 순수 HTTP 클라이언트"라는 원래 책임 경계를 그대로 유지한다. - `OllamaGenerateRequest`에 대한 명시적 유효성 검증은 현재 호출 경로상 불필요하다고 판단해 추가하지 않았다(위 "코드리뷰 반영" 표 참고). 향후 `OllamaClient.generate()`를 다른 곳에서도 직접 호출하게 되는 상황이 생기면 재검토가 필요하다. - ~~`qwen2.5:3b`의 컨텍스트 한도(32,768 토큰)에 대한 명시적 방어(예: 프롬프트가 너무 길면 사전에 잘라내기)는 아직 없다. 지금 topK 범위(1~20)에서는 실질적 위험이 낮아 보류.~~ → 기본 모델이 `qwen2.5:7b`로 바뀌었으나(#184) `qwen2.context_length`는 동일하게 32,768로 확인되어(로컬 `ollama show` 검증) 이 판단은 그대로 유효했다. ~~방어 로직 자체는 여전히 미구현 상태.~~ → `PromptBuilder`에 청크별/전체 컨텍스트 텍스트 예산(truncate) 로직이 추가되어 해결됨 — 문자(코드포인트) 수 기준 근사 방어이며 정밀한 tokenizer 기반은 아니다(`#65` 문서 참고). +- ~~Ollama 서비스 자체(`~/Library/LaunchAgents/homebrew.mxcl.ollama.plist`)에 설정된 `OLLAMA_KV_CACHE_TYPE=q8_0`(KV 캐시 정밀도를 낮추는 옵션)이 답변에 한자/가타카나가 한글 자리에 섞이는 글자 깨짐 현상의 유력 원인으로 지목됐으나, 아직 제거하지 않았다.~~ → 해결됨(`#210`): plist에서 해당 항목을 제거하고 `launchctl` 재로드. `OllamaClient.sanitizeAnswer()` 코드 가드도 별도로 추가해 이중 방어. +- ~~**(신규, `#210`, 미해결)** `trimToSentenceBoundary()`가 `` `ls .` ``처럼 백틱 코드 스팬 안의 마침표를 문장 끝으로 오인하는 경우가 QA에서 재현됨. 마침표 앞의 백틱 개수 홀짝으로 코드 스팬 내부 여부를 판별하는 수정이 필요.~~ → 해결됨: `insideInlineCode` 검사 추가 (PR 리뷰 반영). +- **(신규, `#210`, 미해결)** 내용상 완결된 답변에도 잘림 안내 문구가 붙는 오탐 사례 관찰됨. `hitTokenLimit`/`prematureEnd` 중 어느 조건이 오탐인지 실제 응답 로그로 확인 필요. +- **(신규, `#210`, 보류)** PEG 파서 버그로 `prematureEnd`가 발생했을 때의 재시도 — 확률적 샘플링(temperature 0.3)이라 재시도하면 우회될 가능성이 높지만, 응답 지연과 코드 복잡도 트레이드오프가 있어 `log.warn` 빈도 관찰 후 필요성 재판단하기로 보류. ### 다음 단계 Issue 3 — `RagResponseRepository` + `RagResponseCommandService` 구현. 이번 이슈에서 만든 `OllamaGenerateResult`를 받아 `rag_responses`에 SUCCESS/FAILED 상태로 저장한다. FAILED 기록은 `SearchQueryCommandService.markFailed()`와 동일하게 `@Transactional(propagation = REQUIRES_NEW)` 패턴을 검토한다. + +RAG 타임아웃/컷오프 수정 전체 배경은 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 참고. diff --git a/docs/design/kangcheolung-#75-rag-facade-integration.md b/docs/design/kangcheolung-#75-rag-facade-integration.md index 6c4d9213..66ea7991 100644 --- a/docs/design/kangcheolung-#75-rag-facade-integration.md +++ b/docs/design/kangcheolung-#75-rag-facade-integration.md @@ -218,8 +218,9 @@ public record RagAnswer(String answerText, List citations) { ``` `RagFacade.generate()`의 반환 타입. `SearchOutcome`이 검색 쪽 결과를 담는 그릇이라면, 이건 RAG 쪽 결과를 담는 그릇이다. `citations`는 `ResponseCitationCommandService`가 DB에 저장한 것과 별개로, `candidates`로부터 **직접** 다시 만든다(같은 라벨 규칙 `"[" + order + "]"` 재사용) — DB에 저장한 걸 다시 SELECT해서 응답을 조립하지 않고, 이미 메모리에 있는 값으로 응답도 함께 조립하는 것이다. -### 10. `domain/rag/service/RagFacade.java` — 이번 이슈의 핵심 조율자 +### 10. `domain/rag/service/RagFacade.java` — 이번 이슈의 핵심 조율자 (`#210`에서 후보 수 상한/citations 숨김/extractive fallback 추가) +**현재 코드**: ```java @Transactional @Service @@ -227,6 +228,21 @@ public record RagAnswer(String answerText, List citations) { @Slf4j public class RagFacade { + private static final String LLM_FALLBACK_PREFIX = "AI 답변 생성이 지연되고 있습니다. " + + "가장 관련도 높은 문서에서 다음 내용을 찾았습니다:\n\n"; + + // fallback 문구에 원문을 통째로 붙이면 답변이 지나치게 길어져, 미리보기 수준으로만 잘라 보여준다. + private static final int FALLBACK_EXCERPT_MAX_CODE_POINTS = 300; + + // topK는 호출자가 1~20까지 자유롭게 요청할 수 있어(SearchRequest), 후보 수를 그대로 프롬프트에 + // 다 넣으면 prefill 시간이 예측 불가능해져 read-timeout(25s)을 넘기는 경우가 생긴다. + // 화면에 보여줄 인용 문서 수(topK)와 별개로, LLM이 실제로 읽는 후보 수는 이 값으로 고정한다. + private static final int MAX_PROMPT_CANDIDATES = 3; + + // PromptBuilder가 LLM에게 무관한 문서일 때 이 문구로만 답하도록 지시한다 — 검색은 됐지만(candidates + // 존재) LLM이 무관하다고 판단한 경우, 화면에 근거 문서를 같이 보여주면 안내 문구와 모순돼 보인다. + private static final String NO_RELEVANT_DOC_PHRASE = "관련 문서를 찾지 못했습니다"; + private final PromptBuilder promptBuilder; private final OllamaClient ollamaClient; private final RagResponseCommandService ragResponseCommandService; @@ -245,18 +261,48 @@ public class RagFacade { return RagAnswer.noContext(ragResponse.getAnswerText()); } - // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 - String prompt = promptBuilder.build(queryText, candidates); + // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 (LLM 입력은 상위 MAX_PROMPT_CANDIDATES개로 제한) + List promptCandidates = candidates.size() > MAX_PROMPT_CANDIDATES + ? candidates.subList(0, MAX_PROMPT_CANDIDATES) + : candidates; + String prompt = promptBuilder.build(queryText, promptCandidates); + OllamaGenerateResult result; 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); + result = ollamaClient.generate(prompt); } catch (DocGridException e) { ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage()); - throw e; + // LLM 장애가 권한 검증을 통과한 벡터 검색 결과까지 숨기지 않도록, 최상위 후보 원문을 + // 그대로 인용해 최소한의 답을 제공한다(extractive fallback). + log.warn("[RAG] fallback queryId={} errorCode={}", queryId, e.getErrorCode().getCode()); + return RagAnswer.of(buildExtractiveFallbackAnswer(candidates), candidates); + } + + // LLM 이후의 영속화 실패는 검색 저하 응답으로 숨기지 않고 Transaction 오류로 전달한다. + RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); + responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); + log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); + + // LLM이 무관하다고 판단해 안내 문구로만 답했으면, 후보 문서를 근거처럼 같이 보여주지 않는다. + if (result.answerText() != null && result.answerText().contains(NO_RELEVANT_DOC_PHRASE)) { + return RagAnswer.of(result.answerText(), List.of()); + } + return RagAnswer.of(result.answerText(), candidates); + } + + private String buildExtractiveFallbackAnswer(List candidates) { + VectorSearchCandidate top = candidates.get(0); + String pageSuffix = top.pageNo() != null ? " " + top.pageNo() + "페이지" : ""; + String excerpt = truncate(top.chunkText(), FALLBACK_EXCERPT_MAX_CODE_POINTS); + return "%s\"%s\" (%s%s)".formatted(LLM_FALLBACK_PREFIX, excerpt, top.documentTitle(), pageSuffix); + } + + private String truncate(String text, int maxCodePoints) { + int codePointCount = text.codePointCount(0, text.length()); + if (codePointCount <= maxCodePoints) { + return text; } + int endIndex = text.offsetByCodePoints(0, maxCodePoints - 1); + return text.substring(0, endIndex) + "…"; } } ``` @@ -265,7 +311,30 @@ public class RagFacade { **`@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`)에서 저장된 것이라 영향받지 않고 그대로 남는다. +**실패 시 처리 — extractive fallback (200 응답)**: 최초 구현은 `catch (DocGridException e)`에서 실패 기록만 남기고 예외를 그대로 재전파해 503으로 응답했다. 이후 어느 시점(`#210` 범위 밖, 정확한 이슈 미상)에 "정적 안내 문구 + candidates를 citations로" 반환하는 방식(200 응답)으로 이미 바뀌어 있었고, `#210`에서 그 정적 문구를 **최상위 검색 후보 원문을 최대 300자까지 그대로 인용**하는 방식으로 다시 개선했다 — 실패해도 사용자가 빈손으로 끝나지 않도록. 이때도 이미 커밋된 검색 결과(`search_results`)는 별도 트랜잭션(`SearchFacade`)에서 저장된 것이라 영향받지 않고 그대로 남는다. + +**LLM 입력 후보 수 상한(`MAX_PROMPT_CANDIDATES = 3`, `#210`)**: `topK`는 호출자가 1~20까지 정할 수 있는데(`SearchRequest`), 검색된 후보를 그대로 프롬프트에 다 넣다 보니 후보 개수에 따라 prefill 시간이 들쭉날쭉해 read-timeout을 넘기는 일이 잦았다. 화면에 보여줄 인용 문서 수(`topK`)와 별개로, `PromptBuilder.build()`에 넘기는 후보만 상위 3개로 고정했다 — citations/fallback에는 여전히 전체 `candidates`를 쓴다. + +**LLM이 "무관하다"고 판단하면 citations를 비운다 (`#210`)**: `PromptBuilder`가 무관한 문서일 때 `"관련 문서를 찾지 못했습니다"`로만 답하도록 지시하는데(`#65` 문서), 검색 자체는 성공해서 `candidates`가 비어있지 않은 상태라 기존 로직대로면 이 후보들이 citations로 그대로 노출됐다. "관련 문서 없음" 메시지와 "근거 문서 목록"이 동시에 뜨는 게 모순돼 보여서, 답변이 이 문구를 포함하면 citations를 빈 배열로 반환하도록 분기를 추가했다(DB에는 그대로 저장 — 감사/분석용). **프론트도 같이 고쳐야 했다** — `frontend/app/lib/search-sources.ts`의 `groupSearchSources`가 "citations 비면 원본 검색 `results`로 대체해서 보여주는" fallback을 갖고 있어서, 백엔드만 고치면 이 fallback이 그대로 무력화시켰다. 이 fallback을 제거해 citations만 근거로 렌더링하게 바꿨다. + +**(추가 수정, `#210`) 문구 위치에 따라 처리를 분기**: 위 로직을 처음엔 `answerText.contains(NO_RELEVANT_DOC_PHRASE)` 한 방으로 판정했는데, QA 중 7B 모델이 **정상 답변을 다 끝내놓고 지시문을 메아리처럼 답변 끝에 덧붙이는** 패턴이 반복 관찰됐다(예: 디렉토리 요약을 멀쩡히 마친 뒤 "관련 문서를 찾지 못했습니다. 질문 주제와 관련된 문서가 없습니다."를 스스로 추가). `contains()`로는 이런 경우도 전부 "무관"으로 오판해 멀쩡한 답변의 근거 문서까지 숨겨버렸다. 문구의 **위치**로 분기하도록 고쳤다: + +```java +String answerText = result.answerText(); +int phraseIndex = answerText != null ? answerText.indexOf(NO_RELEVANT_DOC_PHRASE) : -1; +if (phraseIndex >= 0) { + if (answerText.strip().startsWith(NO_RELEVANT_DOC_PHRASE)) { + return RagAnswer.of(answerText, List.of()); + } + log.warn("[RAG] 정상 답변에 무관 안내 문구 혼입, 해당 지점부터 제거: queryId={} phraseIndex={}", + queryId, phraseIndex); + answerText = answerText.substring(0, phraseIndex).strip(); +} +return RagAnswer.of(answerText, candidates); +``` +문구가 답변 맨 앞(사실상 전부)이면 기존대로 진짜 무관 처리(citations 비움). 문구가 중간·끝에 섞여 있으면 그 지점부터 잘라내고 **citations는 유지**한다 — 화면에는 잘린 정상 답변 + 정상 근거 문서가 나간다. `log.warn`으로 발생 빈도를 추적한다. + +전체 배경과 실측 데이터는 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md` 참고. --- @@ -282,9 +351,9 @@ $ ./gradlew build -x test BUILD SUCCESSFUL ``` -기존 검색 블록 테스트(`SearchFacadeTest`, `SearchResultCommandServiceTest`)와 RAG 블록 테스트(`RagResponseCommandServiceTest`, `ResponseCitationCommandServiceTest`)를 이번 이슈의 시그니처 변경에 맞춰 함께 수정했고, 신규 `RagFacadeTest`(NO_CONTEXT/정상/실패 3케이스)를 추가했다. 전체 테스트 스위트가 회귀 없이 통과했다. +기존 검색 블록 테스트(`SearchFacadeTest`, `SearchResultCommandServiceTest`)와 RAG 블록 테스트(`RagResponseCommandServiceTest`, `ResponseCitationCommandServiceTest`)를 이번 이슈의 시그니처 변경에 맞춰 함께 수정했고, 신규 `RagFacadeTest`(NO_CONTEXT/정상/실패 3케이스)를 추가했다. 전체 테스트 스위트가 회귀 없이 통과했다. (`#210`에서 "LLM 무관 판단 시 citations 비움", "후보 3개 초과 시 프롬프트엔 상위 3개만", "무관 문구가 답변 중간에 섞이면 그 지점부터 제거하고 citations는 유지" 3케이스가 추가되어 현재 6케이스다.) -실제 문서 업로드/인덱싱 후 `POST /search`를 Swagger로 호출하는 e2e 확인은 별도로 진행 예정이다(이 문서에는 자동화 테스트 결과만 기록). +실제 문서 업로드/인덱싱 후 `POST /search`를 Swagger로 호출하는 e2e 확인은 이후 QA에서 완료됐다(아래 "다음 단계" 참고 — 그 과정에서 발견된 버그와 수정 내역은 `#210` 문서에 정리). 이 문서에는 자동화 테스트 결과만 기록한다. --- @@ -294,7 +363,8 @@ BUILD SUCCESSFUL |---|---| | 검색 자체 실패(임베딩 서버 장애, 사용자/컬렉션 없음 등) | 기존 `SearchFacade`의 에러 처리 그대로(변경 없음) — `RagFacade`는 호출되지도 않음 | | 접근 가능 문서 0건 / live check로 전부 탈락 | `SearchOutcome.candidates()`가 빈 리스트 → `RagFacade`가 `createNoContext()`로 처리, 200 정상 응답 + 고정 answer 문구 | -| Ollama 호출 실패(타임아웃/연결거부) | `createFailed()`로 FAILED 기록 후 예외 재전파 → 503 `RAG_SERVICE_UNAVAILABLE`. 검색 결과(`search_results`)는 이미 별도 트랜잭션에서 커밋되어 그대로 유지됨 | +| Ollama 호출 실패(타임아웃/연결거부) | `createFailed()`로 FAILED 기록 후 200 + extractive fallback(최상위 후보 원문 최대 300자 인용) answer + `citations`(candidates 전체). 검색 결과(`search_results`)는 이미 별도 트랜잭션에서 커밋되어 그대로 유지됨. (최초 구현은 예외 재전파 → 503이었으나 이후 200 응답으로 바뀜, 위 "10. `RagFacade.java`" 절 참고) | +| LLM이 무관하다고 판단 (`#210`) | 200 + answer(`"관련 문서를 찾지 못했습니다"`) + **citations는 빈 배열** (DB에는 그대로 저장) | | 정상 흐름 | 200 + `results`(검색 후보 전체) + `answer`(LLM 답변) + `citations`(실제 인용된 출처) | --- @@ -344,6 +414,8 @@ PR에 자동 코드리뷰 코멘트 5건이 달렸고, 각각 다음과 같이 **citations 응답은 DB 재조회 없이 메모리의 `candidates`로부터 재구성**: `ResponseCitationCommandService`가 저장한 것과 `RagAnswer.of()`가 만드는 것은 별개의 객체 생성이지만, 소스(`candidates`)와 라벨 규칙(`"[" + order + "]"`)이 동일해 항상 일치한다. +**(`#210` 추가) 화면 표시용 인용 수(topK)와 LLM 입력 후보 수를 분리**: `topK`가 호출자가 자유롭게 정할 수 있는 값이라 그대로 LLM에 넘기면 응답 시간이 예측 불가능해졌다. `citations`/`RagAnswer`는 여전히 전체 `candidates`를 쓰고, `promptBuilder.build()`에 넘기는 것만 `MAX_PROMPT_CANDIDATES`(3)로 별도 제한해 "화면에 보여줄 근거 수"와 "LLM이 실제로 읽는 문맥 크기"라는 서로 다른 관심사를 분리했다. + --- ## 남은 이슈 / TODO @@ -353,4 +425,6 @@ PR에 자동 코드리뷰 코멘트 5건이 달렸고, 각각 다음과 같이 - `SearchQueryCommandService.markFailed()`/`RagResponseCommandService.createFailed()`의 `REQUIRES_NEW` 트랜잭션 경계는 여전히 Mockito 단위 테스트로만 검증되고, Spring 통합 테스트는 없다(`#56`, `#73` 문서에 동일하게 기록된 기존 갭). ### 다음 단계 -RAG 블록(F-RAG-01~05) 전체 구현이 이걸로 완료된다. 이제 실제 문서를 업로드해 인덱싱까지 마친 뒤 Swagger에서 `POST /search`를 직접 호출해, `results` + `answer` + `citations`가 한 응답에 정상적으로 담기는지 e2e로 확인하는 절차가 남아있다. +~~RAG 블록(F-RAG-01~05) 전체 구현이 이걸로 완료된다. 이제 실제 문서를 업로드해 인덱싱까지 마친 뒤 Swagger에서 `POST /search`를 직접 호출해, `results` + `answer` + `citations`가 한 응답에 정상적으로 담기는지 e2e로 확인하는 절차가 남아있다.~~ → 완료됨: 이 e2e 확인이 QA 과정에서 실제로 진행됐고, 그 과정에서 발견된 타임아웃/언어 혼용/컷오프 등 다수의 버그와 수정 내역은 `docs/design/kangcheolung-#210-ollama-rag-timeout-fix.md`에 정리했다. `PromptBuilder` 관련 변경은 `#65`, `OllamaClient` 관련 변경은 `#67` 문서에도 각각 반영했다. + +`#210`에서 새로 남은 미해결 이슈(Ollama `OLLAMA_KV_CACHE_TYPE` 글자 깨짐 등)는 `#67` 문서의 "남은 이슈 / TODO" 및 `#210` 문서 5단계 참고. diff --git a/frontend/app/lib/search-sources.ts b/frontend/app/lib/search-sources.ts index a078a82a..d309561b 100644 --- a/frontend/app/lib/search-sources.ts +++ b/frontend/app/lib/search-sources.ts @@ -21,31 +21,27 @@ type SearchSourceChunk = { similarityScore: number | null; }; -/** Groups citations by document while preserving the search result order and chunk references. */ +/** + * Groups citations by document while preserving the search result order and chunk references. + * + * Renders citations only — never falls back to raw search results when citations is empty. + * An empty citations list is a deliberate signal (no relevant document / RAG judged the + * retrieved chunks irrelevant), not a data gap to paper over. + */ export function groupSearchSources(result: SearchResponse): GroupedSearchSource[] { const resultByChunk = new Map(result.results.map((item) => [item.chunkId, item])); - const chunks: SearchSourceChunk[] = result.citations.length > 0 - ? result.citations.map((citation) => { - const matchingResult = resultByChunk.get(citation.chunkId); - return { - chunkId: citation.chunkId, - documentId: citation.documentId, - documentTitle: citation.documentTitle, - label: citation.label, - excerpt: citation.quotedText, - pageNo: citation.pageNo, - similarityScore: matchingResult ? Number(matchingResult.similarityScore) : null, - }; - }) - : result.results.map((item) => ({ - chunkId: item.chunkId, - documentId: item.documentId, - documentTitle: item.documentTitle, - label: `[${item.rank}]`, - excerpt: item.chunkText, - pageNo: item.pageNo, - similarityScore: Number(item.similarityScore), - })); + const chunks: SearchSourceChunk[] = result.citations.map((citation) => { + const matchingResult = resultByChunk.get(citation.chunkId); + return { + chunkId: citation.chunkId, + documentId: citation.documentId, + documentTitle: citation.documentTitle, + label: citation.label, + excerpt: citation.quotedText, + pageNo: citation.pageNo, + similarityScore: matchingResult ? Number(matchingResult.similarityScore) : null, + }; + }); const groups = new Map }>(); for (const chunk of chunks) { diff --git a/frontend/tests/search-sources.test.ts b/frontend/tests/search-sources.test.ts index bd8acd09..da7f423e 100644 --- a/frontend/tests/search-sources.test.ts +++ b/frontend/tests/search-sources.test.ts @@ -47,10 +47,12 @@ test("deduplicates repeated citations for one chunk", () => { assert.deepEqual(source.excerpts, ["근거"]); }); -test("groups raw search results when citations are unavailable", () => { +test("returns no sources when citations are empty, even if raw search results exist", () => { + // citations가 비어있는 건 "관련 문서 없음"(NO_CONTEXT 또는 RAG가 무관 판단)이라는 의도된 신호이므로, + // 검색 자체는 히트가 있었더라도(results) 근거 문서 섹션에는 아무것도 보여주지 않는다. const response: SearchResponse = { queryId: 13, - answer: null, + answer: "관련 문서를 찾지 못했습니다.", results: [ { rank: 1, documentId: 3, chunkId: 31, documentTitle: "회의록", chunkText: "일정", pageNo: null, similarityScore: 0.7 }, { rank: 2, documentId: 3, chunkId: 32, documentTitle: "회의록", chunkText: "참석자", pageNo: null, similarityScore: 0.6 }, @@ -59,9 +61,5 @@ test("groups raw search results when citations are unavailable", () => { citations: [], }; - const sources = groupSearchSources(response); - assert.equal(sources.length, 2); - assert.deepEqual(sources[0].labels, ["[1]", "[2]"]); - assert.equal(sources[0].chunkCount, 2); - assert.equal(sources[1].documentId, 4); + assert.deepEqual(groupSearchSources(response), []); });