콘텐츠로 이동

리포트 생성

사용자의 자연어 질문을 받아 RAG(Retrieval-Augmented Generation) 파이프라인으로 세법 근거자료를 검색·필터링하고, OpenAI ChatCompletion으로 인용 근거가 포함된 답변을 SSE(Server-Sent Events)로 스트리밍하는 핵심 기능. 답변과 참고자료는 ConversationSession + ConversationTurn으로 영속화되며, 한 세션 안에 멀티턴 Q&A가 누적된다.

사용자 여정

새 세션 시작 (단일 카테고리 또는 카테고리 자동 추론)

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (StreamingConversationController)
    participant SVC as StreamingConversationService
    participant ROUTER as IntentRouter
    participant HYDE as HydeService
    participant RAG as StreamingRagProcessor
    participant ES as Elasticsearch
    participant LLM as OpenAI

    U->>FE: 카테고리 선택 + 질문 입력
    FE->>API: POST /conversations/stream
{ category, question, referenceTypes } API->>API: tokenUsageService.validateTokenQuota(accountId) API->>SVC: startNewSessionWithStreaming(...) SVC->>ROUTER: analyzeNewSession(question, category) Note over ROUTER: intent 분류 + 인덱스 라우팅
(+ category=null이면 카테고리 추론) alt intent = GREETING / OFF_TOPIC SVC->>LLM: 단순 응답 (RAG 없이) SVC-->>FE: chunk + citation_update(empty)
※ 세션 미생성 else intent = TAX_QUESTION + category=null + 추론 점수 충분 SVC-->>FE: category_selection 이벤트
※ 세션 미생성, 사용자 카테고리 선택 대기 else intent = TAX_QUESTION SVC->>SVC: ConversationSession 생성 + 저장 SVC->>HYDE: generateHypotheticalAnswer(question, category) SVC->>SVC: embeddingUtil.embed(가상 답변) SVC->>RAG: processWithStreaming(...) RAG->>ES: 13개 인덱스 hybrid search
(BM25 + vector + RRF) RAG->>RAG: dedup → category backfill
→ tier sort → 주변법 제거 → max 5/type RAG->>LLM: chatCompletions(stream=true)
system prompt + XML refs LLM-->>RAG: SSE chunks RAG-->>SVC: Flow + RagSearchResult SVC-->>FE: metadata 이벤트 (referenceIds + 상세) loop 청크 수신 SVC-->>FE: chunk 이벤트 end SVC->>SVC: parseCitedIds + stripCitationTag
+ replaceDocumentIds SVC->>SVC: ConversationTurn 저장 (+ retrievedDocIds, refs)
recomputeArchiveEligibleWith(turn) SVC->>SVC: 토큰 사용량 기록 (tokenUsageService) SVC->>ES: supplementThreewayReferences (법-시행령-시행규칙) SVC->>ES: supplementReferences (답변 본문 언급 법조문) SVC-->>FE: citation_update (재정렬·보충된 refs) SVC-->>FE: complete (turn 정보 + usageAfter) Note over SVC: KeywordExtractionEvent (AFTER_COMMIT @Async) end

기존 세션에 턴 추가 (멀티턴)

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (StreamingConversationController)
    participant SVC as StreamingConversationService
    participant ROUTER as IntentRouter

    U->>FE: 후속 질문 (예: "그거 예외는?")
    FE->>API: POST /conversations/{sessionId}/turns/stream
    API->>SVC: continueConversationWithStreaming(publicId, ...)
    SVC->>SVC: 세션 + 이전 턴 로드 (publicId)
    SVC->>ROUTER: analyzeContinuation(question, previousTurns)
    Note over ROUTER: intent 분류 + 질문 재작성
("그거" → "양도소득세 비과세 예외는?") alt intent = REFERENCE_SWITCH SVC->>SVC: 이전 질문 재사용 + promptModifier
"이전 인용 문서 제외" else intent = DEPTH_CONTROL SVC->>SVC: 이전 질문 재사용 + promptModifier
"답변 깊이 조절" else intent = TAX_QUESTION SVC->>SVC: rewrittenQuestion으로 검색 end Note over SVC: 이후 흐름은 새 세션과 동일
(HyDE → embed → RAG → stream → 보충 → 저장)

토큰 quota 초과 / 스트리밍 실패

sequenceDiagram
    participant FE as 프론트엔드
    participant API as Backend
    participant SVC as TokenUsageService

    FE->>API: POST /conversations/stream
    API->>SVC: validateTokenQuota(accountId)
    alt quota 초과
        SVC-->>API: TokenQuotaExceededException
        API-->>FE: SSE error 이벤트 (즉시 emitter close)
    else 정상
        API->>API: 스트리밍 시작
        Note over API: 스트리밍 중 quota 초과 시에도
collectAndStream catch 블록에서 동일 처리 end

백엔드 구현

계층 클래스 / 파일 역할
Controller StreamingConversationController (apps/backend/.../controller/StreamingConversationController.kt) /conversations/** SSE 엔드포인트, quota 사전 검증, SseEmitter 라이프사이클
Service StreamingConversationService (apps/backend/.../application/service/conversation/) 세션 생성, 스트리밍 오케스트레이션, citation 재정렬, threeway/answer 기반 보충
Service StreamingRagProcessor 인덱스 라우팅 + ES hybrid search + 필터링·정렬 + LLM 스트리밍 호출
Service IntentRouter 의도 분류(TAX/REFERENCE_SWITCH/DEPTH_CONTROL/GREETING/OFF_TOPIC) + 인덱스 라우팅 + 질문 재작성 + 카테고리 추론 (Phase 1: 단일 LLM 통합)
Service HydeService 검색용 가상 답변 생성 (rag.hyde.enabled로 토글)
Service QueryRewriteService / RagSearchService / IndexRoutingService Phase 0 fallback 경로의 개별 LLM 서비스
Service KeywordExtractionService + KeywordExtractionListener turn 저장 후 @Async @TransactionalEventListener(AFTER_COMMIT)로 키워드 5개 추출
Domain (Entity) ConversationSession (apps/backend/.../application/domain/conversation/) 세션 상태 (account, category, title, isActive, isArchiveEligible, keywords)
Domain (Entity) ConversationTurn 턴 단위 Q&A (userQuestion, rewrittenQuestion, assistantAnswer, retrievedDocIds, langfuseTraceId)
Domain (Entity) TurnReferenceData 턴별 RAG 참조 (dataId + DataType) — conversation_turn_reference_data 테이블
Domain (Entity, legacy) Report (apps/backend/.../application/domain/gpt/Report.kt) 구버전 리포트 엔티티 — 신규 흐름은 ConversationSession/Turn으로 대체됨, admin 페이지에서만 조회
Cleaner ReportCleaner (application/service/report/) 매일 01:00 cron — 클라이언트가 취소한 legacy Report 정리
Repository ConversationSessionRepository / ConversationTurnRepository JPA + 페이징, findByPublicIdAndIsActiveTrueWithTurns 등 fetch-join 쿼리
Persistence ReportRepository (legacy) admin /admin/reports/list 조회 전용

도메인 규칙

규칙 위치 값 / 설명
SSE 타임아웃 StreamingConversationController.SSE_TIMEOUT 180,000 ms (3분) — 초과 시 emitter 자동 close
토큰 quota 사전 검증 StreamingConversationController.startConversationStreaming() tokenUsageService.validateTokenQuota 실패 시 SSE error만 보내고 즉시 종료
HyDE 토글 HydeService.isEnabled() rag.hyde.enabledapplication.yml:64에서 true로 활성화 (@Value fallback은 false). true면 가상 답변 임베딩, false면 원본 질문 임베딩
인덱스 라우팅 알고리즘 IntentRouter + IndexRoutingService intent-router.unified.enabled=true면 단일 LLM이 intent+indices+rewrite+category를 한 번에 처리, 실패 시 Phase 0 (3회 분리 호출) fallback
필수 인덱스 IntentRouter.MANDATORY_INDICES LAW, PRECEDENT, COUNSEL, GLOSSARY — LLM 라우팅 결과에 항상 추가
항상 켜진 타입 StreamingRagProcessor.alwaysOnTypes LAW, PRECEDENT — 카테고리가 ACCOUNTING이면 ACCOUNTING도 추가
답변 정리 파이프라인 StreamingConversationService.collectStreamAndFinalize() (1) <citations>...</citations> 태그에서 cited ID 파싱 → (2) tag strip → (3) raw doc ID를 displayName으로 치환 → (4) 빈 답변이면 "답변을 생성할 수 없습니다."
Citation 기반 재정렬 reorderByCitations() LLM이 인용한 문서를 답변 언급 순서로 최상위에 배치, 미인용은 기존 순서 유지
법령 보충: threeway supplementThreewayReferences() citedIds 또는 답변 본문에 언급된 법령에 한해 tax-threeway 인덱스에서 법-시행령-시행규칙 세트 자동 보충
법령 보충: 답변 본문 supplementReferences() + extractLawReferences() regex로 "법인세법 제40조", "같은 법 시행령 제71조" 등을 추출하여 누락 조문 ES 조회, 최대 3건
법령 최종 cap StreamingConversationService.MAX_TOTAL_LAWS 4-tier 정렬 후 상위 8건만 유지 (Issue #129)
미인용 법령 제거 filterAndSortForCitationUpdate() citedIds + 답변 언급 + 보충에 포함된 것만 남김 (citation 또는 mention이 0건이면 원래 순서 유지)
4-tier 정렬 키 LawClassificationConfig.getLawSortTier() [세법 → 카테고리 대표법 → 주변법] + [법 → 시행령 → 시행규칙] + 조문 번호
청크 인코딩 StreamingConversationController.sendEvent() 공백을 %20으로 치환 — SSE 전송 중 trim 방지 (프론트가 디코드)
Archive eligibility 갱신 ConversationSession.recomputeArchiveEligibleWith(turn) 턴 추가 시 isArchiveEligible = newTurnValid && existingAllValid (Issue #148: 한 턴이라도 invalid면 false)
Archive 적격 조건 ConversationTurn.isValidForArchive() refs 비어있음 OR 답변이 검색된 참고 자료/죄송로 시작 OR 세무와 관련된 포함 시 invalid
키워드 추출 트리거 StreamingConversationService.collectStreamAndFinalize() turn 저장 트랜잭션 안에서 KeywordExtractionEvent publish → AFTER_COMMIT @Async로 LLM 호출 (latency 영향 0)
Legacy Report 자동 정리 ReportCleaner.removeCanceledReport() 매일 01:00 cron — isClientCanceled=true인 legacy Report 일괄 삭제

API 엔드포인트

Method Path 설명
POST /conversations/stream 새 대화 세션 + 첫 턴 (SSE)
POST /conversations/{sessionId}/turns/stream 기존 세션에 턴 추가 (SSE)
GET /conversations 사용자 활성 세션 목록 (카테고리 필터 옵션)
GET /conversations/{sessionId} 세션 상세 (소유자 검증 + editable 플래그)
DELETE /conversations/{sessionId} 세션 비활성화 (soft delete: isActive=false)
GET /conversations/token-usage 일일 토큰 사용량 조회
POST /admin/conversations/backfill-keywords 적격 세션 중 keywords가 NULL인 항목을 LLM으로 백필 (admin)
GET /admin/reports/list legacy Report 목록 (카테고리별 페이징)
DELETE /admin/reports/{reportId} legacy Report soft-delete

스펙 상세(request/response schema)는 API 레퍼런스 / OpenAPI 페이지에서 endpoint별 검색.

SSE 이벤트 종류

Event 페이로드 발생 시점
metadata { rewrittenQuestion, referenceIds, referenceDetails } 검색 완료 직후, 첫 chunk 이전
chunk string (공백 → %20 인코딩) LLM 응답 토큰마다
category_selection { message, categories[] } category=null로 시작했고 추론 점수가 충분할 때 (세션 미생성, 사용자 선택 대기)
citation_update { referenceIds, referenceDetails, lowRelevanceTypes } 답변 완료 후 보충(threeway + answer-based) + 재정렬 결과
complete { turn: ConversationTurnResponse, usageAfter: TokenUsageInfo } 모든 처리 완료 시 (저장된 턴 + 갱신된 토큰 사용량)
error string quota 초과, RAG/LLM 예외, IO 오류 — emitter 즉시 close

데이터 모델

erDiagram
    CONVERSATION_SESSION {
        int session_id PK
        string public_id "UUID — 외부 노출"
        string account_id FK "소유자"
        string category "LawCategory enum, nullable"
        string title "첫 질문 100자"
        boolean is_active "soft delete"
        boolean is_archive_eligible "공개 게시판 노출 여부, nullable"
        string keywords "comma-separated max 5, nullable"
        datetime created_date_time
        datetime last_modified_date_time
    }
    CONVERSATION_TURN {
        int turn_id PK
        int session_id FK
        int turn_number "세션 내 1부터 순번"
        string user_question "원본 질문"
        string rewritten_question "Query Rewrite 결과, nullable"
        string assistant_answer "최종 답변 (citation tag 제거됨)"
        string langfuse_trace_id "트레이싱 ID, nullable"
        datetime created_date_time
    }
    CONVERSATION_TURN_RETRIEVED_DOC_IDS {
        int turn_id FK
        string doc_id "ES 문서 ID (멀티 row)"
    }
    CONVERSATION_TURN_REFERENCE_DATA {
        int id PK
        int turn_id FK
        string data_id "ES 문서 ID"
        string data_type "DataType enum (LAW/EXAM/COUNSEL/...)"
    }
    REPORT {
        int report_id PK "legacy"
        string account_id FK
        string initial_question
        string adjusted_question
        string answer
        string current_process "ReportProcess enum"
        string report_category "LawCategory, nullable"
        boolean is_client_canceled "default true"
        boolean is_deleted
    }
    REFERENCE_DATA {
        int reference_data_id PK "legacy Report 종속"
        int report_id FK
        string data_id
        string data_type
    }
    CONVERSATION_SESSION ||--o{ CONVERSATION_TURN : "1:N (OrderBy turnNumber)"
    CONVERSATION_TURN ||--o{ CONVERSATION_TURN_RETRIEVED_DOC_IDS : "1:N (ElementCollection)"
    CONVERSATION_TURN ||--o{ CONVERSATION_TURN_REFERENCE_DATA : "1:N (cascade ALL)"
    REPORT ||--o{ REFERENCE_DATA : "1:N (legacy)"

설정

항목 위치 비고
HyDE 활성화 application.yml:64 rag.hyde.enabled YAML 기본 true (@Value fallback은 false이지만 yaml 우선). HydeService가 LLM 호출
Unified IntentRouter intent-router.unified.enabled 기본 false → Phase 0 (3회 LLM). true → Phase 1 (1회 통합 LLM, 실패 시 fallback)
Long-term memory conversation.memory.long-term.enabled 기본 false. true면 ConversationMemoryService로 다른 세션 컨텍스트 prepend
LLM 모델 / 프롬프트 Langfuse 프롬프트 (rag-final-answer, rag-final-answer-multiturn, hyde-generator, intent-router, query-rewrite, tax-category-inference, keyword-extraction) TTL 5분 캐시 (langfuse.prompt.cache-ttl-seconds). 미설정 시 코드 fallback (gpt-5.4-nano 등)
OpenAI key yaml openai.token env var OPENAI_TOKEN으로 override 가능 (Spring relaxed binding). OPENAI_API_KEY는 사용 안 함
Elasticsearch application-{prod,local}.yml spring.elasticsearch.* 14개 인덱스: tax-laws, tax-precedents, tax-counsel, tax-written-inquiry, tax-glossary, tax-enforcement, tax-oldnew, tax-basic-rules, tax-taxoffice, tax-tribunal, tax-supreme-court, tax-scourt, tax-accounting, tax-threeway
Async executor AsyncConfig.kt @Async (KeywordExtractionListener, ConversationMemoryIndexingService) — ThreadPoolTaskExecutor 적용 (Issue #148 carryover)
Cron — legacy 정리 ReportCleaner.@Scheduled(cron = "0 0 1 * * ?") 매일 01:00

알려진 이슈 / 개선 예정

  • Legacy Report 도메인 미사용: 신규 chat 흐름은 ConversationSession/ConversationTurn만 쓴다. Report/ReferenceData/SimilarData 테이블은 admin 조회 + ReportCleaner cron만 남아 있고, 신규 코드는 작성하지 않는다. 추후 schema cleanup 후보.
  • 백필 endpoint timeout 위험 (/admin/conversations/backfill-keywords): 동기 처리 (LLM 1건당 ~500ms). limit=100이면 ~50초, limit=500이면 ~4분 — ALB/Cloudflare 기본 timeout 60s 초과 시 클라이언트는 응답을 못 받지만 백엔드는 계속 실행. prod에서는 limit=100으로 chunk해서 호출하거나 비동기 job으로 전환 권장 (Issue #148 미해결).
  • TaxCategoryInferenceService가 Langfuse temperature/maxTokens 무시: KeywordExtractionService는 fix됐지만 TaxCategoryInferenceService.analyzeQuestionWithLlm()은 여전히 Langfuse config의 temperature/maxTokens를 ChatCompletionRequest에 전달하지 않음. 같은 패턴 fix 필요 (Issue #148 carryover).
  • @Async 스레드 풀 — historical: 과거에 SimpleAsyncTaskExecutor(매 호출마다 새 스레드)를 사용해 burst 시 위험했으나, AsyncConfig에 bounded ThreadPoolTaskExecutor가 적용되어 해결됨 (CLAUDE.md prod readiness 기록 참조).
  • HyDE prod 설정 미명시: 공통 application.ymltrue로 활성화돼 있으나 application-prod.yml에 명시 override 없음. prod에서 의도적으로 끄려면 prod profile에 명시적 false 설정 필요.
  • intent-router.unified.enabled prod 미설정: prod yaml에 명시 없음 — 기본 false (Phase 0, LLM 3회 호출)로 동작. Phase 1(단일 호출)을 원하면 prod에서 명시적으로 true 설정 필요.
  • SSE 3분 타임아웃: SSE_TIMEOUT = 180_000L이 hardcoded. 매우 긴 답변 (멀티턴 + 큰 컨텍스트)은 잘릴 수 있음 — 모니터링 필요.
  • ALB idle_timeout 정합성: ALB의 idle_timeout=300s가 SSE_TIMEOUT(180s)보다 길어서 타임아웃 시점에 backend가 먼저 close → ALB가 502를 안 던지지만, 반대로 늘릴 경우 ALB와 동기화 필요.
  • Citation tag 누락 시: LLM이 <citations>...</citations>를 안 붙이면 parseCitedIds()가 빈 리스트 반환 → filterAndSortForCitationUpdate()가 원래 순서 유지 (필터링 비활성). 프롬프트 변경 시 회귀 주의.

관련 문서