Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,17 @@ dependencyManagement {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-vector-store'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
useJUnitPlatform()
testLogging {
showStandardStreams = true
}

// .env 파일에서 환경변수 로딩
def loadDotEnv = { task ->
def envFile = rootProject.file('.env')
if (envFile.exists()) {
envFile.readLines().each { line ->
Expand All @@ -46,9 +46,22 @@ tasks.named('test') {
if (idx > 0) {
def key = trimmed.substring(0, idx).trim()
def value = trimmed.substring(idx + 1).trim()
environment(key, value)
task.environment(key, value)
}
}
}
}
}

tasks.named('test') {
useJUnitPlatform()
testLogging {
showStandardStreams = true
}

loadDotEnv(delegate)
}

tasks.named('bootRun') {
loadDotEnv(delegate)
}
22 changes: 22 additions & 0 deletions data/eval_result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"total": 150,
"correct": 66,
"incorrect": 84,
"error": 0,
"accuracy": 0.44,
"tier_results": {
"easy": {
"correct": 13,
"total": 30
},
"medium": {
"correct": 47,
"total": 94
},
"hard": {
"correct": 6,
"total": 26
}
},
"elapsed_seconds": 468.1384799480438
}
48 changes: 47 additions & 1 deletion data/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ def judge_answer(question: str, expected: str, actual: str) -> dict:
return {"score": 0, "reason": "판정 파싱 실패"}


def next_result_file_path(data_dir: Path) -> Path:
"""기존 결과 파일이 있으면 번호를 붙여 새 파일명을 반환합니다."""
default_path = data_dir / "eval_result.json"
if not default_path.exists():
return default_path

index = 1
while True:
candidate = data_dir / f"eval_result_{index}.json"
if not candidate.exists():
return candidate
index += 1


# ─── 메인 ─────────────────────────────────────────────────────────────────────

def main():
Expand Down Expand Up @@ -133,6 +147,7 @@ def main():

results = {"correct": 0, "incorrect": 0, "error": 0}
tier_results = {}
detailed_results = []
start_time = time.time()

for i, q in enumerate(questions):
Expand All @@ -149,6 +164,16 @@ def main():
response = ask_server(question_ko)
if response is None:
results["error"] += 1
detailed_results.append({
"id": qid,
"tier": tier,
"question": question_ko,
"expected_answer": expected,
"actual_answer": "",
"score": 0,
"reason": "서버 응답 없음",
"status": "error",
})
if args.verbose:
print(f"[{qid}] ERROR — 서버 응답 없음")
continue
Expand All @@ -167,6 +192,17 @@ def main():
results["incorrect"] += 1
marker = "✗"

detailed_results.append({
"id": qid,
"tier": tier,
"question": question_ko,
"expected_answer": expected,
"actual_answer": actual_answer,
"score": score,
"reason": judgment.get("reason", ""),
"status": "correct" if score == 1 else "incorrect",
})

if args.verbose:
print(f"[{qid}] {marker} ({tier}) {question_ko[:40]}...")
if score == 0:
Expand Down Expand Up @@ -197,8 +233,17 @@ def main():
print(f"\n소요 시간: {elapsed:.1f}초")
print(f"평균 응답: {elapsed/max(total,1):.1f}초/질문")

incorrect_results = [item for item in detailed_results if item["status"] == "incorrect"]
if incorrect_results:
print("\n오답 판정 이유:")
for item in incorrect_results[:10]:
print(f" [{item['id']}] {item['question']}")
print(f" 이유: {item['reason']}")
if len(incorrect_results) > 10:
print(f" ... 외 {len(incorrect_results) - 10}건은 결과 파일을 확인하세요.")

# 결과 저장
result_file = DATA_DIR / "eval_result.json"
result_file = next_result_file_path(DATA_DIR)
with open(result_file, "w") as f:
json.dump({
"total": total,
Expand All @@ -208,6 +253,7 @@ def main():
"accuracy": results["correct"] / max(total, 1),
"tier_results": tier_results,
"elapsed_seconds": elapsed,
"detailed_results": detailed_results,
}, f, indent=2, ensure_ascii=False)
print(f"\n결과 저장: {result_file}")

Expand Down
53 changes: 43 additions & 10 deletions mission/wall-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,69 @@

> 구현하면서 잘 안 됐던 것, 예상과 달랐던 것을 적어주세요.

-
[처음 시작]

- 어떻게 구현하는지조차 잘 몰라서 바로 hint1을 봐야했습니다.
- 챗봇을 구현할 때에 어떤 것을 고려해서 구현해야하는지도 전혀 몰랐습니다. 그래서 RAG가 무엇인지, 임베딩은 언제 수행되는지, 벡터 계산은 어떻게 시키는건지 차근차근
알아가보려고 했습니다.
- 임베딩 과정을 통해 문서와 질문을 벡터로 변환하고, 이 벡터를 `VectorStore`에 저장 + 검색에 사용한다는 점을 알아봤습니다. 질문이 들어오면 먼저 관련 문서를
`VectorStore`에서 찾고, 그 검색 결과를 문맥으로 받은 Chat API가 생성한다는 구조를 대략적으로 받아들이고 진행했습니다.
- 챗봇 기능 구현 자체는 ai를 통해 진행했습니다.

[구현 과정 중]

- 처음에는 chat log를 전부 포함하려고 했는데, 문서 수가 많아 앱 시작 시 임베딩 호출이 너무 오래 걸렸습니다. 그래서 chat log는
`agent_accuracy=correct`인 데이터만 사용하도록 줄이고, FAQ와 policy는 각각 제목 단위로 chunking해서 `VectorStore`에
넣어보았습니다.
- 그리고 평가가 어떤 기준으로 되는지 모르겠고, 어떤 지점을 변경해야 평가지표가 좋아지는지도 감이 전혀 오질 않았습니다.
- 프롬포트를 다듬어야 하는건지, RAG 검색 topK값을 변경 해야 하는건지, chunking 기준을 변경 해야 하는건지, chat log 데이터에서 다른 것을 포함해야하는지
감이 오질 않았습니다. 그래서 하나씩 해봤는데 품질에 별다른 변화가 없었다고 느꼈습니다.

## 2. 해결하지 못한 것

> 시도했지만 결국 해결 못한 문제가 있다면 적어주세요.

-

- 프롬프트, topK, chunking 기준을 바꿔보며 정확도를 높이려고 했지만, 어떤 변경이 점수 향상에 가장 큰 영향을 주는지 명확히 파악하지 못했습니다.
- 검색 결과 로깅을 추가해 어떤 문서 chunk가 검색되는지는 확인할 수 있게 했지만, 오답의 원인이 검색 단계에 있는지 답변 생성 단계에 있는지 체계적으로 구분하지는
못했습니다.
- 검색된 문서가 맞았는데도 답변이 부족한 경우와, 애초에 잘못된 문서가 검색된 경우를 나누어 분석하는 방법을 아직 잘 모르는 것 같습니다.
- chatlog를 활용해보려고 했지만, 처음에는 데이터가 너무 많아 임베딩 시간이 오래 걸렸습니다. agent_accuracy=correct인 데이터만 사용하도록 줄였지만, 실제
정확도 향상에 도움이 되는지 노이즈가 되는지 파악하지 못했습니다.
- 부분적으로 맞는 답변도 score=0으로 처리되는 경우가 있는 것 같습니다. 점수를 더 세분화하면 개선 방향을 분석하는 데 도움이 될 수 있을 지 궁금했지만,
현재 평가 기준 자체를 바꾸는 것이 적절한지는 판단하지 못했습니다.

## 3. 정확도 측정 결과

> 테스트 질문 100개로 측정한 정확도를 기록해주세요.

| 난이도 | 정확도 | 비고 |
|--------|--------|------|
| easy | | |
| medium | | |
| hard | | |
테스트 질문 150개 기준으로 측정했습니다. (`eval_result.json`결과 작성)

| 난이도 | 정확도 | 비고 |
|--------|---------------|-----------------------------|
| easy | 43.3% (13/30) | 기본 질문에서도 조건/예외 누락으로 오답 발생 |
| medium | 50.0% (47/94) | 가장 높은 정확도이나 세부 정책 누락이 많음 |
| hard | 23.1% (6/26) | 복합 조건, 예외 정책, 최신 정책 구분에서 취약 |

## 4. 왜 그런 결과가 나왔는지

> 정확도가 낮은 난이도의 질문을 몇 개 살펴보고, 왜 틀렸는지 분석해주세요.

-
완전히 다른 답변을 한 경우보다는 핵심 사실의 '일부'만 포함한 경우(함께 설명되어야 하는 조건, 예외, 제한사항을 빠뜨리는 경우)가 많았습니다.

하나의 질문에 여러 정책 조항이 함께 필요한 경우 일부 정보만 답변에 반영되어 정확도가 낮았습니다.
=> 관련 문서를 어느 정도 찾았더라도, 답변 생성 과정에서 필요한 조건을 모두 종합하지 못하면 오답이 되었습니다.

현재 구현은 topK로 검색된 일부 chunk만 문맥으로 전달하기 때문에, 필요한 근거가 검색 결과에 포함되지 않거나, 포함되더라도 답변에서 충분히 사용되지 않는 문제가
있었습니다.

## 5. 개선하고 싶은 것

> 시간이 더 있었다면 시도해보고 싶은 개선점을 적어주세요.

-
- 임베딩과 벡터 검색의 원리가 궁금합니다. 제공되는 힌트를 보니까 cosine similarity, 벡터 차원 등의 키워드가 있던데 아직 잘 모릅니다...
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분은 수학적인 개념이 깊게 필요한 부분이라 제가 설명드리기 어려울 것 같고... (저도 모름 ㅠㅠ)
제가 학습할 때 참고한 영상들 아래 링크로 올려둘게요~

- incorrect하다고 판단한 원인을 좀 더 알아보고 싶습니다. 로깅을 통해 어떤 chunk가 검색되었는지는 확인할 수 있지만, 오답이 검색 실패 때문인지, 검색된 문서를
충분히 활용하지 못한 답변 생성 문제인지 모르겠습니다. 그래서 더 어떤 시도를 해야하는지 답답했던 것 같습니다.(제가 늦게 참여해서 그런 걸까요..?)
Comment on lines +70 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"어떤 시도를 해야 할지 답답했다" 라는 것은 적용할 때 어떤 것을, 어떻게, 왜 하는지에 대한 명확한 기준이 없었기 때문일 확률이 높을 것 같은데요.
프롬프트, topK, chunking, chat log 데이터를 "하나씩 해봤는데 변화가 없었다"고 하셨는데 어떤 것이 문제인지 모르는 상태에서 적용하다 보면 효과가 있는 변경과 없는 변경이 서로 상쇄돼서 "변화 없음"으로 보일 수도 있어요.

먼저 "검색 실패인지 생성 실패인지 구분하는 방법" 자체가 해당 미션의 중요한 포인트기 때문에, 인지했다는 것 만으로도 가치가 있다 생각이 들고,
해당 영역을 어떻게 해볼 수 있을지를 더 깊게 학습하시면 좋을 것 같아요!


> 추가로 궁금한 것

- 점수 계산은 어떤 것을 기준으로 하는지 궁금합니다. 점수가 높을 수록 실제 사용감이 좋아지는건지 궁금합니다.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것이 보예가 설정하고 개선할 핵심입니다 ㅋㅋ
LLM 자체는 불확실성을 기반으로 하기 때문에 기존 개발과 같이 결정론적(deterministic) 사고를 하면 어려울 수 있어요.

측정과 개선 또한 보예의 가설을 세우고 측정하고 개선하는 방향으로 접근해보길 권장드려요!
내가 만든 챗봇의 목적을 정의하고, 목적을 기반으로 기준을 세우고, 그 기준으로 측정하며 개선 하기!

22 changes: 22 additions & 0 deletions src/main/java/com/cholog/bootcamp/AiConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.cholog.bootcamp;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

@Bean
VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}

@Bean
ChatClient chatClient(ChatClient.Builder chatClientBuilder) {
return chatClientBuilder.build();
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/cholog/bootcamp/chat/ChatController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.cholog.bootcamp.chat;

import com.cholog.bootcamp.chat.dto.ChatRequest;
import com.cholog.bootcamp.chat.dto.ChatAnswerResponse;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatController {

private final ChatService chatService;

public ChatController(ChatService chatService) {
this.chatService = chatService;
}

@PostMapping("/api/chat")
public ChatAnswerResponse chat(@RequestBody ChatRequest request) {
return chatService.ask(request.question());
}
}
103 changes: 103 additions & 0 deletions src/main/java/com/cholog/bootcamp/chat/ChatService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.cholog.bootcamp.chat;

import com.cholog.bootcamp.chat.dto.ChatAnswerResponse;
import jakarta.annotation.PostConstruct;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {

private final ChatClient chatClient;
private final VectorStore vectorStore;
private final RagProperties ragProperties;
private final DocumentLoader documentLoader;

@PostConstruct
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PostConstruct
void loadFaqContext() {
    vectorStore.add(documentLoader.load());
}

이 구조의 트레이드오프:

  • ChatService답변 생성임베딩 초기화 두 책임을 가짐 (SRP 약함)
  • 테스트에서 ChatService 인스턴스 만들 때마다 vector add 실행 → OpenAI API 호출 비용 발생 가능
  • 임베딩 실패 시 ChatService Bean 생성 자체가 실패 → 서버 부팅 실패

"이 챗봇은 한 가지를 잘 하는가" 라는 질문을 ChatService에 던졌을 때 "답변 + 초기화 둘 다" 가 되는 게 분리가 필요한 신호일 수 있어요~

void loadFaqContext() {
vectorStore.add(documentLoader.load());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 구조는 애플리케이션이 시작될 때마다 모든 문서를 다시 읽고 임베딩하여 VectorStore에 추가합니다. 이는 시작 시간을 지연시키고 불필요한 API 비용을 발생시킵니다. SimpleVectorStoresave/load 기능을 사용하여 벡터 데이터를 파일로 캐싱하거나, 영구 저장소 기반의 VectorStore 도입을 고려해 보세요.

}

public ChatAnswerResponse ask(String question) {
List<Document> retrievedDocuments = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(ragProperties.getTopK())
.build()
);

logSearchResults(question, retrievedDocuments);

String supportContext = retrievedDocuments
.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n===\n\n"));

ChatResponse response = chatClient.prompt()
.system("""
- 당신은 Cholog Corporation의 고객 전용 챗봇 서비스이다.
- 제공된 컨텍스트만을 활용하라.
- 제공된 컨텍스트로 답할 수 없다면, '고객센터에 문의해주세요'라고 답하라.
- 한국어로 답하라.
""")
.user("""
Customer question:
%s
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

system prompt가 ChatService에 인라인이네요. "고객센터에 문의해주세요" 라는 거절 규칙이 들어 있는 만큼 외부 파일로 분리하면 좋겠습니다.

외부화 시 장점:

  • 운영자가 거절 문구를 PR로 수정 가능
  • 톤 변경 (A/B) 자연스러움
  • prompt 자체에 대한 회귀 테스트 가능 (evaluate.py가 일부 역할)

별도로 — Customer question / Support context 가 영어인 게 의도된 것인지 한 번 확인해보세요. LLM은 한국어 user question + 영어 label을 어떻게 해석하는지에 따라 출력 언어가 흔들리는 경우가 있어요. 호출 직전에 advisor로 실제 입력을 log로 찍어보면 확인할 수 있습니다~


Support context:
%s
""".formatted(question, supportContext))
.call()
.chatResponse();

Usage usage = response.getMetadata().getUsage();

return new ChatAnswerResponse(
response.getResult().getOutput().getText(),
new ChatAnswerResponse.TokenUsage(
usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens(),
usage == null || usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens(),
usage == null || usage.getTotalTokens() == null ? 0 : usage.getTotalTokens()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage null 방어 좋네요 👍

usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens()

한 칸 더 가본다면, 아래 케이스도 같이 다뤄볼 수 있어요.

  • chatResponse 자체가 null인 경우 (rate limit, network) — 현재는 response.getResult().getOutput().getText() 에서 NPE
  • vector store 검색 실패 — similaritySearch 가 예외 던지면 그대로 사용자에게 500
  • getResult() 가 null인 경우

사용자에게 "일시 장애로 답변이 어렵습니다" 메시지를 명시적으로 돌려주는 분기 하나 추가하면, 운영자가 alert 잡는 데도 도움이 될 수 있어요. 더 나아가 에러를 LLM이 다음 결정에 활용 가능한 형태로 만드는 사고 도 한 번 고민해보면 좋겠습니다!

)
);
Comment on lines +67 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

LLM 응답 결과가 비어있을 경우 response.getResult()null을 반환하여 NullPointerException이 발생할 수 있습니다. 응답 존재 여부를 확인한 후 안전하게 텍스트를 추출하도록 개선이 필요합니다.

        String answer = (response.getResult() != null && response.getResult().getOutput() != null)
            ? response.getResult().getOutput().getText()
            : "고객센터에 문의해주세요.";

        return new ChatAnswerResponse(
            answer,
            new ChatAnswerResponse.TokenUsage(
                usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens(),
                usage == null || usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens(),
                usage == null || usage.getTotalTokens() == null ? 0 : usage.getTotalTokens()
            )
        );

}

private void logSearchResults(String question, List<Document> documents) {
String resultSummary = documents.isEmpty()
? "no documents retrieved"
: documents.stream()
.map(this::formatDocumentSummary)
.collect(Collectors.joining(" | "));

log.info("RAG search question='{}' topK={} results={}",
question,
ragProperties.getTopK(),
resultSummary
);
}

private String formatDocumentSummary(Document document) {
Map<String, Object> metadata = document.getMetadata();
String sourceType = String.valueOf(metadata.getOrDefault("sourceType", "UNKNOWN"));
String source = String.valueOf(metadata.getOrDefault("source", "UNKNOWN"));
Object sectionTitle = metadata.get("sectionTitle");

if (sectionTitle == null) {
return "%s/%s".formatted(sourceType, source);
}

return "%s/%s#%s".formatted(sourceType, source, sectionTitle);
}
}
Loading