diff --git a/app.py b/app.py index 7eefd77..872d7bc 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -"""Gradio Web UI — 율봇 Phase 4 + Phase 9/10 + Phase 14(음성).""" +"""Gradio Web UI — 율봇 Phase 4 + Phase 9/10 + Phase 12(피드백) + Phase 14(음성).""" import os import subprocess import tempfile @@ -17,6 +17,7 @@ db.init_schema() ingestion = container.ingestion_service() retriever = container.retriever_service() +feedback_repo = container.feedback_repository() _cfg = container.config() _agent_cache: dict[str, AgentService] = {} @@ -44,7 +45,7 @@ def transcribe_audio(filepath: str) -> str: def tts_speak(text: str, voice: str) -> str | None: - """텍스트를 macOS say 명령어로 음성 변환, 재생용 wav 파일 경로 반환.""" + """텍스트를 macOS say 명령어로 음성 변환, 재생용 aiff 파일 경로 반환.""" if not text: return None try: @@ -77,36 +78,72 @@ def _get_agent(user_id: str) -> AgentService: return _agent_cache[user_id] -async def respond(message, history, show_thinking, user_id, use_tts): +async def respond(message, history, show_thinking, user_id, use_tts, run_ids): if not message.strip(): - yield history, "", None + yield history, "", None, run_ids return agent = _get_agent(user_id) history = list(history) + run_ids = list(run_ids) history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": ""}) - yield history, "", None + yield history, "", None, run_ids async for token in agent.stream_response(message, show_thinking=show_thinking): history[-1]["content"] += token - yield history, "", None + yield history, "", None, run_ids + + run_ids.append(agent.last_run_id) if use_tts: response_text = history[-1]["content"] audio_path = tts_speak(response_text, _cfg.tts_voice) - yield history, "", audio_path + yield history, "", audio_path, run_ids + else: + yield history, "", None, run_ids + + +def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): + idx = like_data.index + if isinstance(idx, (list, tuple)): + idx = idx[0] + if not isinstance(idx, int) or idx >= len(history): + return + if history[idx].get("role") != "assistant": + return + asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant") + run_id = run_ids[asst_turn] if asst_turn < len(run_ids) else None + + def _to_str(val) -> str: + return val if isinstance(val, str) else str(val) + + user_msg = _to_str(history[idx - 1]["content"]) if idx > 0 else "" + asst_msg = _to_str(history[idx]["content"]) + rating = 1 if like_data.liked else -1 + + try: + feedback_repo.save_feedback(user_id, user_msg, asst_msg, rating, run_id) + except Exception as e: + print(f"[Feedback] DB 저장 실패: {e}") + + if run_id and os.getenv("LANGCHAIN_TRACING_V2") == "true": + try: + from langsmith import Client + Client().create_feedback(run_id=run_id, key="user_feedback", score=rating) + except Exception as e: + print(f"[Feedback] LangSmith 기록 실패: {e}") def switch_user(user_id): - """사용자 전환 시 채팅 화면만 초기화 (대화 이력은 유지).""" - return [] + """사용자 전환 시 채팅 화면과 run_ids 초기화 (대화 이력은 DB에 유지).""" + return [], [] def reset_chat(user_id): agent = _get_agent(user_id) agent.reset() - return [] + return [], [] def ingest_files(files): @@ -143,6 +180,7 @@ with gr.Blocks(title="율봇") as demo: gr.Markdown("# 율봇\n육아·금융 전문 AI 상담 도우미") user_state = gr.State(DEFAULT_USER) + run_ids_state = gr.State([]) with gr.Tab("대화"): with gr.Row(): @@ -185,7 +223,7 @@ with gr.Blocks(title="율봇") as demo: user_selector.change( switch_user, inputs=[user_selector], - outputs=[chatbot], + outputs=[chatbot, run_ids_state], ).then( lambda u: u, inputs=[user_selector], outputs=[user_state] ) @@ -198,15 +236,21 @@ with gr.Blocks(title="율봇") as demo: send_btn.click( respond, - inputs=[msg_box, chatbot, show_thinking, user_state, use_tts], - outputs=[chatbot, msg_box, tts_output], + inputs=[msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state], + outputs=[chatbot, msg_box, tts_output, run_ids_state], ) msg_box.submit( respond, - inputs=[msg_box, chatbot, show_thinking, user_state, use_tts], - outputs=[chatbot, msg_box, tts_output], + inputs=[msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state], + outputs=[chatbot, msg_box, tts_output, run_ids_state], + ) + reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state]) + + chatbot.like( + handle_feedback, + inputs=[chatbot, run_ids_state, user_state], + outputs=[], ) - reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot]) with gr.Tab("문서 등록"): gr.Markdown("PDF 또는 TXT 파일을 업로드하면 율봇이 내용을 참고해 답변합니다.") diff --git a/config.py b/config.py index c7db764..9590fec 100644 --- a/config.py +++ b/config.py @@ -34,7 +34,13 @@ class Config(BaseSettings): # RAG rag_top_k: int = 3 - semantic_breakpoint_threshold_type: str = "percentile" # percentile | standard_deviation | interquartile + semantic_breakpoint_threshold_type: str = "percentile" # percentile | standard_deviation | interquartile | gradient + semantic_buffer_size: int = 1 # 인접 문장 몇 개를 묶어 임베딩할지 (1=단일 문장, 2=전후 1문장 포함) + + # Reranker (RERANKER_ENABLED=true 시 활성화) + reranker_enabled: bool = False + reranker_model_id: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1" # 한국어 지원 다국어 모델 + reranker_fetch_k: int = 10 # rerank 전 벡터 검색 후보 수 (rag_top_k보다 커야 함) rag_verbose: bool = False rag_show_sources: bool = False langgraph_verbose: bool = False diff --git a/container.py b/container.py index 06a3a01..2745b4a 100644 --- a/container.py +++ b/container.py @@ -9,11 +9,13 @@ from services.chat.compact_service import CompactService from services.db.mysql_service import DatabaseService from services.db.conversation_repository import ConversationRepository from services.db.user_profile_repository import UserProfileRepository +from services.db.feedback_repository import FeedbackRepository from services.ui.cli_service import CliUiService from services.events.event_bus import EventBus from services.events.handlers import StreamTokenHandler, StreamEndHandler from langchain_huggingface import HuggingFaceEmbeddings from services.rag.ingestion_service import IngestionService +from services.rag.rerank_service import RerankService from services.rag.retriever_service import RetrieverService from services.agent.agent_service import AgentService @@ -60,6 +62,11 @@ class Container(containers.DeclarativeContainer): db=db_service, ) + feedback_repository = providers.Singleton( + FeedbackRepository, + db=db_service, + ) + history_service = providers.Factory( HistoryService, system_prompt=providers.Callable(lambda c: c.system_prompt, config), @@ -97,6 +104,12 @@ class Container(containers.DeclarativeContainer): breakpoint_threshold_type=providers.Callable( lambda c: c.semantic_breakpoint_threshold_type, config ), + buffer_size=providers.Callable(lambda c: c.semantic_buffer_size, config), + ) + + reranker = providers.Callable( + lambda c: RerankService(c.reranker_model_id) if c.reranker_enabled else None, + config, ) retriever_service = providers.Singleton( @@ -105,6 +118,8 @@ class Container(containers.DeclarativeContainer): qdrant_url=providers.Callable(lambda c: c.qdrant_url, config), collection_name=providers.Callable(lambda c: c.qdrant_collection, config), top_k=providers.Callable(lambda c: c.rag_top_k, config), + reranker=reranker, + rerank_fetch_k=providers.Callable(lambda c: c.reranker_fetch_k, config), ) # Phase 3 — LangGraph Agent diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index aa7e84b..44d139d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,10 +4,11 @@ | 영역 | 현황 | |------|------| -| LLM | Qwen3-14B-4bit (MLX, Apple Silicon) | +| LLM | Qwen3-8B-4bit (MLX, Apple Silicon) | | Agent | LangGraph ReAct + Tool Calling + Thinking 모드 | -| RAG | Qdrant + BAAI/bge-m3 임베딩 + Semantic Chunking (`_SemanticSplitter`) | +| RAG | Qdrant + BAAI/bge-m3 임베딩 + Semantic Chunking (`SemanticChunker`) + Reranker (BAAI/bge-reranker-v2-m3) | | Tools | `search_documents`, `web_search`, `get_current_date`, `remember_user_info`, `recall_user_info` (5개) | +| Feedback | Gradio 👍/👎 → `td_feedback` DB 저장 + LangSmith `create_feedback()` 연동 | | UI | CLI + Gradio Web UI + 음성 입력(STT)/출력(TTS) | | Memory | LangGraph MemorySaver (세션 내) + MySQL 대화 저장 + 장기 사용자 프로필 | | Tracing | LangSmith 트레이싱 | @@ -31,6 +32,13 @@ DB 스키마(`td_conversations.user_id`, `td_user_profile.user_id`)는 `_migrate ### ✅ 버그 4 — 나이 계산 오류 (수정 완료) LLM이 훈련 데이터 기준 연도로 나이를 계산하는 문제. `AgentService.call_model()`에서 매 호출 시 시스템 프롬프트 앞에 `오늘 날짜: {date.today().isoformat()}`를 주입. 프로필에서 생년월일/생년 값을 파싱해 한국 나이(현재연도-출생연도+1)와 만 나이(생일 기준 정확 계산)를 자동 계산해 시스템 프롬프트에 포함. +### ✅ 버그 5 — 사고 과정(thinking) 체크박스 무효 (수정 완료) +ON/OFF와 무관하게 사고 과정이 표시되지 않던 버그. +- `call_model` 내부에서 `get_stream_writer()`로 thinking 토큰을 custom 이벤트로 emit → 답변 앞에 먼저 스트리밍 +- 체크박스 값을 LangGraph configurable → `llm_with_tools.bind(enable_thinking=...)` 로 모델 레벨까지 전달 (`.env` `ENABLE_THINKING` 설정과 독립) +- `stream_response` 루프를 `stream_mode=["messages", "custom"]` 이중 스트림으로 전환 +- `self._think_verbose` 인스턴스 변수 참조 버그 수정 (`_think_verbose` 로컬 변수 사용) + --- ## ✅ Phase 4 — Web UI (Gradio) @@ -97,43 +105,42 @@ turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10) --- -## Phase 12 — 답변 피드백 & 품질 개선 ★★☆ +## ✅ Phase 12 — 답변 피드백 & 품질 개선 **배경**: 에이전트가 잘못된 답변을 해도 피드백 루프가 없어 개선이 어려움. -**구현 범위**: -- Gradio 채팅 메시지마다 👍 / 👎 버튼 -- `td_feedback` 테이블에 메시지·평점 저장 -- LangSmith의 `run_id`와 연결해 피드백을 트레이스에 기록 (`langsmith.Client().create_feedback()`) - -```sql -CREATE TABLE td_feedback ( - id INT AUTO_INCREMENT PRIMARY KEY, - message TEXT, - response TEXT, - rating TINYINT, -- 1: 좋음, -1: 나쁨 - langsmith_run_id VARCHAR(100), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` +**구현 내용**: +- Gradio Chatbot 메시지마다 👍 / 👎 버튼 (`chatbot.like()` 이벤트) +- `td_feedback` 테이블에 `user_id`, 질문, 답변, 평점 저장 (`FeedbackRepository`) +- `AgentService`에서 응답마다 `run_id`(UUID)를 LangChain config에 주입 → `last_run_id` property로 노출 +- `run_ids_state`(gr.State)로 대화 턴별 `run_id` 추적 +- LangSmith `Client().create_feedback()` 연동 (트레이싱 활성화 시 자동 기록) **난이도**: 중간 | **임팩트**: 중간 (장기 품질 향상) --- -## Phase 13 — RAG 품질 향상 ★★☆ (부분 완료) +## ✅ Phase 13 — RAG 품질 향상 ★★★ (완료) **배경**: 고정 크기 청킹 + 벡터 유사도 검색만으로는 관련 없는 청크가 섞일 수 있음. **✅ Semantic Chunker — 완료** -- `_SemanticSplitter` 클래스 직접 구현 (`services/rag/ingestion_service.py`) -- `langchain-experimental` 사용 없이 numpy + 기존 BAAI/bge-m3 임베딩으로 구현 -- 인접 문장 간 코사인 유사도 계산 → 유사도 하위 5% 지점에서 청크 분리 -- `config.py`에서 `rag_chunk_size` / `rag_chunk_overlap` 제거 → `semantic_breakpoint_threshold_type` 추가 -**🔲 미완 — Reranker** -1. **Reranker 추가** — `cross-encoder/ms-marco-MiniLM-L-6-v2`로 검색 결과 재순위 -2. **top_k 조정** — 검색 후 rerank → 상위 3개만 LLM에 전달 +커스텀 `_SemanticSplitter`를 제거하고 `langchain_experimental.SemanticChunker`로 교체 (`services/rag/ingestion_service.py`). +기존에 무시되던 `semantic_breakpoint_threshold_type` 설정이 이제 실제로 적용된다. + +| 기능 | 지원 여부 | +|------|----------| +| breakpoint_threshold_type | ✅ percentile / standard_deviation / interquartile / gradient | +| buffer_size | ✅ `SEMANTIC_BUFFER_SIZE` 환경변수로 설정 | +| min_chunk_size | ✅ (SemanticChunker 기본 지원) | +| HuggingFaceEmbeddings 재사용 | ✅ 기존 임베딩 모델 그대로 사용 | + +> **langchain-experimental 패키지 상태**: +> `langchain-experimental` v0.4.2는 공식 유지보수 종료가 선언됐지만([#87](https://github.com/langchain-ai/langchain-experimental/issues/87)), +> `SemanticChunker` 자체는 현재 정상 동작하며 후속 패키지(`langchain-text-splitters`)로 이전 완료 시 migration 예정. + +**✅ 미완 1 — Semantic Chunker 기능 완성 (완료)** > 기존 Qdrant 저장 문서는 재등록해야 새 청킹 방식이 적용됨. @@ -157,26 +164,67 @@ CREATE TABLE td_feedback ( --- -## Phase 15 — 예방접종·건강검진 알림 스케줄러 ★★☆ +## ✅ Phase 13-B — Reranker ★★☆ -**배경**: 아이 생년을 기억하고 있으므로, 예방접종 일정(BCG, DTaP 등)을 자동 계산해 알림을 줄 수 있음. 율봇의 차별화 포인트. +**배경**: 벡터 유사도 검색은 의미적으로 비슷한 청크를 가져오지만, 질문과 실제로 관련 있는 청크를 정확히 가려내지 못하는 경우가 있다. Reranker는 검색 후 순위를 재조정해 LLM에 전달되는 컨텍스트 품질을 높인다. -**구현 방식**: -- `td_user_profile`에서 아이 생년 조회 → 예방접종 스케줄 계산 Tool -- Gradio "건강 일정" 탭: 달력형 일정 표시 -- APScheduler로 당일 알림 (또는 Gradio 시작 시 오늘 일정 배너) +**구현 내용**: +- `services/rag/rerank_service.py` — `RerankService` 클래스 (Cross-Encoder 래퍼) +- `RetrieverService.search()`: reranker 활성화 시 `rerank_fetch_k`(기본 10)개 후보 검색 → rerank → 상위 `rag_top_k`(기본 3)개 반환 +- `tools.py` `make_retriever_tool`: `as_retriever()` → `search()` 직접 호출로 변경 (reranker 자동 적용) +- `.env` `RERANKER_ENABLED=true`로 활성화, 기본 비활성 (첫 실행 시 모델 다운로드) -```python -@tool -def get_vaccination_schedule(birth_year: int, birth_month: int) -> str: - """아이 생년월을 기반으로 예방접종 일정을 계산합니다.""" -``` +| 설정 | 기본값 | 설명 | +|------|--------|------| +| `RERANKER_ENABLED` | `false` | `true`로 설정 시 활성화 | +| `RERANKER_MODEL_ID` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | 한국어 포함 다국어 모델 (117MB) | +| `RERANKER_FETCH_K` | `10` | rerank 전 벡터 검색 후보 수 | -**난이도**: 중간 | **임팩트**: 높음 (육아 특화 차별화) +**난이도**: 중간 | **임팩트**: 높음 (관련성 낮은 청크 필터링 → 답변 정확도 향상) --- -## Phase 16 — 모델 선택 (Claude API / OpenAI 옵션) ★☆☆ +## Phase 18 — Hybrid Search (BM25 + Vector) ★★☆ + +**배경**: 한국어 질문에서 고유명사·전문용어가 포함된 경우 의미 검색(Dense)만으로는 recall이 떨어진다. BM25 키워드 검색과 결합(Hybrid)하면 보완이 가능하다. + +**구현 방식**: +- Qdrant의 Sparse Vector 지원 활용 (`FastEmbedSparseEmbeddings` 또는 BM42) +- 인덱싱 시 dense + sparse 두 벡터 동시 저장 +- 검색 시 `RRF(Reciprocal Rank Fusion)`로 결과 통합 +- `IngestionService`, `RetrieverService` 양쪽 수정 필요 + +**난이도**: 중간 | **임팩트**: 높음 (키워드 포함 질문 recall 대폭 향상) + +--- + +## Phase 19 — Query Rewriting ★☆☆ + +**배경**: 사용자 구어체 질문("아이가 밥을 안 먹어요")은 벡터 검색에 최적화되어 있지 않다. LLM이 검색 전에 질문을 재작성하면 관련 문서 검색 확률이 높아진다. + +**구현 방식**: +- LangGraph에 `query_rewrite` 노드 추가 (agent → query_rewrite → tools 순서) +- 또는 `search_documents` 도구 내부에서 rewrite 후 검색 +- 프롬프트: "다음 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요" + +**난이도**: 하 | **임팩트**: 중간 (구어체 질문 검색 품질 향상) + +--- + +## Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆ + +**배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다. + +**구현 방식**: +- `ragas` 라이브러리로 Faithfulness·Answer Relevancy·Context Recall 자동 측정 +- 테스트 질문-정답 셋을 `eval/` 디렉터리에 관리 +- 설정 변경 후 `python eval/run_ragas.py`로 비교 가능 + +**난이도**: 중간 | **임팩트**: 중간 (장기 품질 관리 기반) + +--- + +## Phase 15 — 모델 선택 (Claude API / OpenAI 옵션) ★☆☆ **배경**: 로컬 MLX 모델은 Apple Silicon 전용. 원격 접속 시나리오나 더 높은 품질이 필요할 때 Claude API/OpenAI를 선택할 수 있으면 유연성 확보. @@ -190,7 +238,7 @@ model_provider: str = "mlx" # "mlx" | "claude" | "openai" --- -## Phase 17 — Docker 컨테이너화 ★☆☆ +## Phase 16 — Docker 컨테이너화 ★☆☆ **배경**: 현재 로컬 전용. 가족이나 지인도 쓸 수 있도록 서버 배포 가능한 형태로 패키징. @@ -202,13 +250,13 @@ docker-compose.yml └── mysql ``` -> 주의: MLX는 Apple Silicon 전용이라 서버 배포 시 Phase 16(모델 선택)이 선행되어야 함. +> 주의: MLX는 Apple Silicon 전용이라 서버 배포 시 Phase 15(모델 선택)이 선행되어야 함. **난이도**: 높음 | **임팩트**: 중간 --- -## Phase 18 — 멀티모달 이미지 이해 ★☆☆ +## Phase 17 — 멀티모달 이미지 이해 ★☆☆ **배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등. @@ -221,10 +269,10 @@ docker-compose.yml ## 추천 진행 순서 ``` -단기 (1~2주) 중기 (1개월) 장기 -──────────────── ────────────────── ────────────── -Phase 15 (알림) → Phase 13 Reranker → Phase 17 (Docker) -Phase 12 (피드백) Phase 16 (모델선택) Phase 18 (멀티모달) +단기 (1~2주) 중기 (1개월) 장기 +──────────────────────── ────────────────────── ────────────────── +Phase 18 Hybrid Search → Phase 15 (모델선택) → Phase 16 (Docker) +Phase 19 Query Rewriting → Phase 20 (RAGAS 평가) → Phase 17 (멀티모달) ``` ### 우선순위 매트릭스 @@ -235,6 +283,7 @@ Phase 12 (피드백) Phase 16 (모델선택) Phase 18 (멀티모달) | 버그 2 이력 미연동 | ✅ 완료 | — | — | — | | 버그 3 단일 사용자 | ✅ 완료 | — | — | — | | 버그 4 나이 계산 오류 | ✅ 완료 | — | — | — | +| 버그 5 thinking 체크박스 무효 | ✅ 완료 | — | — | — | | Phase 4 Web UI | ✅ 완료 | — | — | — | | Phase 5 장기 사용자 메모리 | ✅ 완료 | — | — | — | | Phase 6 웹 검색 | ✅ 완료 | — | — | — | @@ -242,11 +291,13 @@ Phase 12 (피드백) Phase 16 (모델선택) Phase 18 (멀티모달) | Phase 9 문서 관리 | ✅ 완료 | — | — | — | | Phase 10 멀티유저 | ✅ 완료 | — | — | — | | Phase 11 이력 복원 | ✅ 완료 | — | — | — | +| Phase 12 피드백 | ✅ 완료 | — | — | — | | Phase 13 Semantic Chunker | ✅ 완료 | — | — | — | | Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — | -| Phase 15 예방접종 알림 | 🔲 미완 | 중간 | 높음 | ⭐ 1순위 | -| Phase 12 피드백 | 🔲 미완 | 중간 | 중간 | 2순위 | -| Phase 13 Reranker | 🔲 진행 중 | 중간 | 중간 | 3순위 | -| Phase 16 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 | -| Phase 17 Docker | 🔲 미완 | 높음 | 중간 | 5순위 | -| Phase 18 멀티모달 | 🔲 미완 | 높음 | 높음 | 6순위 | +| Phase 13-B Reranker | ✅ 완료 | — | — | — | +| Phase 18 Hybrid Search | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 | +| Phase 19 Query Rewriting | 🔲 신규 | 하 | 중간 | 3순위 | +| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 | +| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 5순위 | +| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 6순위 | +| Phase 17 멀티모달 | 🔲 미완 | 높음 | 높음 | 7순위 | diff --git a/docs/thinking-feature-analysis.md b/docs/thinking-feature-analysis.md new file mode 100644 index 0000000..7f77c20 --- /dev/null +++ b/docs/thinking-feature-analysis.md @@ -0,0 +1,150 @@ +# 사고 과정 표시 기능 분석 보고서 + +**테스트 일시**: 2026-05-28 +**테스트 질문**: "논문 결과가 어떻게 돼?" +**앱 버전**: http://localhost:7860 + +--- + +## 테스트 결과 요약 + +| 항목 | 사고 과정 OFF | 사고 과정 ON | +|------|-------------|------------| +| 총 소요 시간 | 200.5s | 233.7s | +| 1단계 (질문 분석) | 59.8s | 77.4s | +| 사고 과정 블록 표시 | 없음 | **없음 (버그)** | +| 최종 답변 내용 | 6개 섹션, 동일 | 6개 섹션, 동일 | +| 답변 차이 | **없음** | **없음** | + +**결론: ON/OFF 체크박스가 현재 아무런 시각적 차이를 만들지 않는다.** + +--- + +## 실제 응답 (두 경우 모두 동일) + +``` +[LangGraph → agent: 질문 분석 중] (59.84s) + +문서 검색 중... ("어머니의 반응성 상호작용이 아동의 중심축 행동과 지능 및 다중지능 발달에 미치는 영향") + +[LangGraph → tools: 도구 실행 중] (71.18s) + [결과: 3개 문서 반환 → agent 복귀] + +[문서 검색: "어머니의 반응성 상호작용이 아동의 중심축 행동과 지능 및 다중지능 발달에 미치는 영향"] + → [문서 1] 1, 81-99 어머니의 반응성 상호작용이 아동의 중심축 행동... + → [문서 2] 김정미․정은주/ 어머니의반응성상호작용이... + → [문서 3] 김정미․정은주/ 어머니의반응성상호작용이... + +[LangGraph → agent: 검색 결과 반영 중] (132.91s) + +[LangGraph → agent: 최종 답변 생성] + +본 연구의 결과는 다음과 같이 요약할 수 있습니다: +1. 어머니의 반응성 상호작용과 아동의 중심축 행동 간의 관계 + ... +``` + +사고 과정 ON을 선택했을 때 기대되는 `[사고 과정]...[/사고 과정]` 블록이 나타나지 않음. + +--- + +## 원인 분석 + +### 구조적 문제 + +``` +LLM 생성 흐름: + 사고 내용... → 최종 답변 텍스트 + ↓ ↓ + AIMessageChunk AIMessageChunk + content="" content="본 연구의..." + additional_kwargs= additional_kwargs={} + {"thinking": "..."} +``` + +#### 핵심 병목: `call_model` 내부 누적 방식 + +`agent_service.py:111`의 `call_model` 함수는 LLM 청크를 내부에서 모두 누적한 뒤 **단일 `AIMessage`로 반환**한다: + +```python +async for chunk in llm_with_tools.astream(msgs, config): + thinking_acc += chunk.additional_kwargs.get("thinking", "") + content_acc += chunk.content or "" + ... +return {"messages": [AIMessage(content=content_acc, additional_kwargs={"thinking": thinking_acc})]} +``` + +LangGraph `stream_mode="messages"`는 내부 LLM 청크를 외부로 통과시키지만, +사고 청크(`content=""`, `additional_kwargs={"thinking":"..."}`)는 +빈 content로 인해 **LangGraph 스트림에서 필터링**되거나 전달되지 않는 것으로 보인다. + +결과적으로 `stream_response`가 수신하는 청크: + +| 수신되는 것 | 수신 안 되는 것 | +|-----------|--------------| +| content가 있는 `AIMessageChunk` | **thinking이 있는 `AIMessageChunk`** | +| 최종 `AIMessage` (thinking 포함) | | + +#### 왜 최종 `AIMessage`의 thinking도 표시 안 되는가 + +`stream_response:221`의 조건이 이를 차단한다: + +```python +elif node == "agent" and isinstance(chunk, AIMessage): + if not content_started and not thinking_open: # ← content_started=True면 전체 스킵 + thinking = chunk.additional_kwargs.get("thinking", "") + if thinking and _think_verbose: + yield "\n[사고 과정]\n" + ... +``` + +content `AIMessageChunk`들이 먼저 처리되면서 `content_started = True`가 세팅됨. +최종 `AIMessage`가 도착할 때는 이미 `content_started=True`라 전체 블록이 실행되지 않는다. + +--- + +## 적용된 버그 수정 (2026-05-28) + +### 수정 1: `agent_service.py:223` — 인스턴스 변수 참조 오류 + +```diff +- if thinking and self._think_verbose: # 항상 False (config 기본값) ++ if thinking and _think_verbose: # 체크박스 값 사용 +``` + +이 수정은 엣지케이스(content 스트리밍 없이 최종 AIMessage만 도달하는 경우)에서 체크박스를 올바르게 반영한다. +그러나 정상 스트리밍 경로에서는 `content_started=True` 조건이 여전히 블록을 막는다. + +--- + +## 제안하는 추가 수정 + +`stream_response`에서 최종 `AIMessage`의 thinking을 저장해두고, +스트리밍 루프 종료 후 표시하는 방식이 가장 간단하다: + +```python +# 루프 내 - AIMessage 처리 시 thinking 저장 +elif node == "agent" and isinstance(chunk, AIMessage): + if not thinking_open: + deferred_thinking = chunk.additional_kwargs.get("thinking", "") + if chunk.content and not content_started: + ... + +# 루프 종료 후 +if deferred_thinking and _think_verbose: + yield "\n\n---\n**[사고 과정]**\n\n" + yield deferred_thinking + yield "\n\n**[/사고 과정]**\n" +``` + +> 단, thinking이 답변 뒤에 표시되는 UX 트레이드오프가 있다. +> 답변 전에 표시하려면 `call_model`을 리팩토링해 thinking을 먼저 스트리밍해야 한다. + +--- + +## 소요 시간 비교 참고 + +ON이 OFF보다 약 33초 더 걸린 점은 주목할 만하다. +`enable_thinking=True`(config 설정)로 모델이 항상 thinking을 생성하므로, +ON/OFF 간 소요 시간 차이는 모델 비결정성(temperature)에 의한 자연 편차로 보인다. +체크박스는 표시 여부만 제어하며 모델 동작 자체는 바꾸지 않는다. diff --git a/services/agent/agent_service.py b/services/agent/agent_service.py index 9d82dc3..78d0472 100644 --- a/services/agent/agent_service.py +++ b/services/agent/agent_service.py @@ -6,6 +6,7 @@ from typing import AsyncIterator from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver +from langgraph.config import get_stream_writer from langgraph.graph import START, MessagesState, StateGraph from langgraph.prebuilt import ToolNode, tools_condition @@ -43,6 +44,7 @@ class AgentService: self._conv_id: int | None = None self._pending_history: list = [] self._user_id = user_id + self._last_run_id: str | None = None if conversation_repository: try: @@ -107,10 +109,19 @@ class AgentService: system_content += f"\n\n## 사용자 정보 (이전 대화에서 기억된 내용)\n" + "\n".join(lines) msgs = [SystemMessage(content=system_content)] + state["messages"] thinking_acc, content_acc, tool_calls_acc = "", "", [] - async for chunk in llm_with_tools.astream(msgs, config): + try: + writer = get_stream_writer() + except Exception: + writer = None + # 체크박스 값을 모델의 enable_thinking으로 전달 (런타임 오버라이드) + show_thinking = config.get("configurable", {}).get("show_thinking", False) + _llm = llm_with_tools.bind(enable_thinking=show_thinking) if show_thinking != chat_model.enable_thinking else llm_with_tools + async for chunk in _llm.astream(msgs, config): t = chunk.additional_kwargs.get("thinking", "") if t: thinking_acc += t + if writer: + writer({"__thinking": t}) if chunk.content and isinstance(chunk.content, str): content_acc += chunk.content if chunk.tool_calls: @@ -132,13 +143,18 @@ class AgentService: self._agent = builder.compile(checkpointer=MemorySaver()) @property - def _config(self) -> dict: - return {"configurable": {"thread_id": self._thread_id}} + def last_run_id(self) -> str | None: + return self._last_run_id + + def _make_config(self, show_thinking: bool = False) -> dict: + return {"configurable": {"thread_id": self._thread_id, "show_thinking": show_thinking}} async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str]: """사용자 입력을 받아 응답 토큰을 순서대로 yield한다.""" _think_verbose = show_thinking if show_thinking is not None else self._think_verbose self._source_buffer.clear() + run_id = uuid.uuid4() + run_config = {**self._make_config(_think_verbose), "run_id": str(run_id)} # 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입 if self._pending_history: @@ -155,13 +171,42 @@ class AgentService: content_started = False # 노드 당 레이블 1회 출력 제어 start_time = time.perf_counter() - async for chunk, metadata in self._agent.astream( - messages, self._config, stream_mode="messages" + async for stream_event in self._agent.astream( + messages, run_config, stream_mode=["messages", "custom"] ): + mode, data = stream_event + + # ── custom 이벤트 — call_model writer가 emit한 thinking 토큰 ── + if mode == "custom": + if isinstance(data, dict) and "__thinking" in data: + # thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신 + if "agent" != prev_node: + if thinking_open: + yield "\n[/사고 과정]\n" + thinking_open = False + content_started = False + if lg: + elapsed = time.perf_counter() - start_time + label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중" + yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n" + prev_node = "agent" + if _think_verbose: + if not thinking_open: + yield "\n[사고 과정]\n" + thinking_open = True + yield data["__thinking"] + continue + + # ── messages 이벤트 ────────────────────────────────────── + chunk, metadata = data node = metadata.get("langgraph_node", "") # ── 노드 전환 시 플래그 리셋 + 레이블 출력 ────────────── + # (agent 레이블은 custom 이벤트 핸들러에서 이미 처리될 수 있으므로 중복 방지) if node != prev_node: + if thinking_open: + yield "\n[/사고 과정]\n" + thinking_open = False content_started = False if lg: if node == "agent": @@ -175,13 +220,6 @@ class AgentService: # ── agent 노드 — AIMessageChunk만 처리 (중복 방지) ────── if node == "agent" and isinstance(chunk, AIMessageChunk): - thinking = chunk.additional_kwargs.get("thinking", "") - if thinking and _think_verbose: - if not thinking_open: - yield "\n[사고 과정]\n" - thinking_open = True - yield thinking - if chunk.tool_calls: if thinking_open: yield "\n[/사고 과정]\n" @@ -213,7 +251,7 @@ class AgentService: elif node == "agent" and isinstance(chunk, AIMessage): if not content_started and not thinking_open: thinking = chunk.additional_kwargs.get("thinking", "") - if thinking and self._think_verbose: + if thinking and _think_verbose: yield "\n[사고 과정]\n" yield thinking yield "\n[/사고 과정]\n" @@ -247,6 +285,8 @@ class AgentService: if thinking_open: yield "\n[/사고 과정]\n" + self._last_run_id = str(run_id) + # 대화 내용을 MySQL에 저장 if self._conv_repo and self._conv_id and response_content: try: diff --git a/services/agent/tools.py b/services/agent/tools.py index 56af318..9aa6e25 100644 --- a/services/agent/tools.py +++ b/services/agent/tools.py @@ -24,15 +24,14 @@ def web_search(query: str) -> str: def make_retriever_tool(retriever_service): - """as_retriever()를 사용하는 단순 검색 Tool (source_buffer 없음).""" - retriever = retriever_service.as_retriever() + """retriever_service.search()를 사용하는 검색 Tool (Reranker 자동 적용).""" @tool def search_documents(query: str) -> str: """등록된 문서(논문, 육아 가이드, 금융 자료 등)에서 관련 정보를 검색합니다. 육아·금융 관련 질문이 오면 자신의 지식으로 답하기 전에 반드시 이 도구를 먼저 호출하세요. 등록된 문서가 없거나 검색 결과가 없을 때만 자신의 학습 지식을 보조적으로 활용합니다.""" - docs = retriever.invoke(query) + docs = retriever_service.search(query) if not docs: return "관련 문서를 찾을 수 없습니다." return "\n\n".join( diff --git a/services/db/feedback_repository.py b/services/db/feedback_repository.py new file mode 100644 index 0000000..6456cb4 --- /dev/null +++ b/services/db/feedback_repository.py @@ -0,0 +1,19 @@ +class FeedbackRepository: + def __init__(self, db): + self._db = db + + def save_feedback( + self, + user_id: str, + message: str, + response: str, + rating: int, + langsmith_run_id: str | None = None, + ) -> None: + self._db.execute_write( + """ + INSERT INTO td_feedback (user_id, message, response, rating, langsmith_run_id) + VALUES (%s, %s, %s, %s, %s) + """, + (user_id, message, response, rating, langsmith_run_id), + ) diff --git a/services/db/mysql_service.py b/services/db/mysql_service.py index 86cfa39..ec55001 100644 --- a/services/db/mysql_service.py +++ b/services/db/mysql_service.py @@ -99,6 +99,17 @@ class DatabaseService: UNIQUE KEY uq_user_key (user_id, key_name) ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS td_feedback ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(50) NOT NULL DEFAULT 'default', + message TEXT, + response TEXT, + rating TINYINT, + langsmith_run_id VARCHAR(100), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) conn.commit() self._migrate_schema(conn) diff --git a/services/model/mlx_chat_model.py b/services/model/mlx_chat_model.py index 7f8f038..f8fb209 100644 --- a/services/model/mlx_chat_model.py +++ b/services/model/mlx_chat_model.py @@ -82,7 +82,13 @@ class MlxChatModel(BaseChatModel): }) return result - def _build_prompt(self, messages: List[BaseMessage], tools: Optional[list] = None) -> str: + def _build_prompt( + self, + messages: List[BaseMessage], + tools: Optional[list] = None, + enable_thinking: Optional[bool] = None, + ) -> str: + _enable_thinking = enable_thinking if enable_thinking is not None else self.enable_thinking kwargs: dict = { "tokenize": False, "add_generation_prompt": True, @@ -91,7 +97,7 @@ class MlxChatModel(BaseChatModel): kwargs["tools"] = tools # Qwen3 thinking 모드 — 지원하지 않는 모델은 무시됨 try: - kwargs["enable_thinking"] = self.enable_thinking + kwargs["enable_thinking"] = _enable_thinking return self._tokenizer.apply_chat_template(self._to_chat_dicts(messages), **kwargs) except TypeError: kwargs.pop("enable_thinking") @@ -145,7 +151,8 @@ class MlxChatModel(BaseChatModel): from mlx_lm import generate tools = kwargs.get("tools") - prompt = self._build_prompt(messages, tools) + enable_thinking_override = kwargs.pop("enable_thinking", None) + prompt = self._build_prompt(messages, tools, enable_thinking=enable_thinking_override) text = generate( self._model, self._tokenizer, @@ -169,7 +176,9 @@ class MlxChatModel(BaseChatModel): from mlx_lm import stream_generate tools = kwargs.get("tools") - prompt = self._build_prompt(messages, tools) + enable_thinking_override = kwargs.pop("enable_thinking", None) + _enable_thinking = enable_thinking_override if enable_thinking_override is not None else self.enable_thinking + prompt = self._build_prompt(messages, tools, enable_thinking=_enable_thinking) OPEN_THINK = "" CLOSE_THINK = "" @@ -178,7 +187,7 @@ class MlxChatModel(BaseChatModel): SAFE = max(len(OPEN_THINK), len(CLOSE_THINK), len(OPEN_TOOL), len(CLOSE_TOOL)) # enable_thinking=False 모델은 블록을 생성하지 않으므로 post_think에서 시작 - state = "pre_think" if self.enable_thinking else "post_think" + state = "pre_think" if _enable_thinking else "post_think" buf = "" out: list[ChatGenerationChunk] = [] diff --git a/services/rag/ingestion_service.py b/services/rag/ingestion_service.py index 1e9bd2a..7797da9 100644 --- a/services/rag/ingestion_service.py +++ b/services/rag/ingestion_service.py @@ -1,59 +1,10 @@ -import re - -import numpy as np from langchain_community.document_loaders import PDFPlumberLoader, TextLoader -from langchain_core.documents import Document +from langchain_experimental.text_splitter import SemanticChunker from langchain_qdrant import QdrantVectorStore from qdrant_client import QdrantClient from qdrant_client.models import Filter, FieldCondition, MatchValue, FilterSelector -def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: - return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-10)) - - -class _SemanticSplitter: - """문장 임베딩 유사도 기반 청커. - - 인접 문장 간 코사인 유사도를 계산하고, 유사도가 낮은(= 의미 전환) 지점에서 청크를 분리한다. - breakpoint_percentile=95이면 유사도 하위 5% 지점이 분리 경계가 된다. - """ - - _SENTENCE_RE = re.compile(r"(?<=[.!?。!?])\s+") - - def __init__(self, embeddings, breakpoint_percentile: int = 95): - self._embeddings = embeddings - self._percentile = breakpoint_percentile - - def split_documents(self, docs: list[Document]) -> list[Document]: - result = [] - for doc in docs: - for chunk_text in self._split_text(doc.page_content): - result.append(Document(page_content=chunk_text, metadata=doc.metadata)) - return result - - def _split_text(self, text: str) -> list[str]: - sentences = [s for s in self._SENTENCE_RE.split(text.strip()) if s.strip()] - if len(sentences) <= 1: - return [text.strip()] if text.strip() else [] - - vecs = np.array(self._embeddings.embed_documents(sentences)) - similarities = [_cosine_similarity(vecs[i], vecs[i + 1]) for i in range(len(vecs) - 1)] - threshold = float(np.percentile(similarities, 100 - self._percentile)) - breakpoints = [i + 1 for i, s in enumerate(similarities) if s < threshold] - - chunks, start = [], 0 - for bp in breakpoints: - chunk = " ".join(sentences[start:bp]).strip() - if chunk: - chunks.append(chunk) - start = bp - tail = " ".join(sentences[start:]).strip() - if tail: - chunks.append(tail) - return chunks - - class IngestionService: """문서를 의미 단위 청크로 분할해 Qdrant에 저장하는 수집 파이프라인.""" @@ -63,12 +14,16 @@ class IngestionService: qdrant_url: str, collection_name: str, breakpoint_threshold_type: str = "percentile", + buffer_size: int = 1, ): self._embeddings = embeddings self._qdrant_url = qdrant_url self._collection_name = collection_name - # breakpoint_threshold_type은 향후 확장용으로 수용 (현재는 percentile 방식 고정) - self._splitter = _SemanticSplitter(embeddings, breakpoint_percentile=95) + self._splitter = SemanticChunker( + embeddings=embeddings, + breakpoint_threshold_type=breakpoint_threshold_type, + buffer_size=buffer_size, + ) self._client = QdrantClient(url=qdrant_url) def _delete_by_source(self, source_path: str) -> None: diff --git a/services/rag/rerank_service.py b/services/rag/rerank_service.py new file mode 100644 index 0000000..820d7cc --- /dev/null +++ b/services/rag/rerank_service.py @@ -0,0 +1,19 @@ +from langchain_core.documents import Document + + +class RerankService: + """Cross-Encoder 기반 재순위(Reranker) 서비스.""" + + def __init__(self, model_id: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"): + from sentence_transformers import CrossEncoder + print(f"Reranker 로딩 중: {model_id}") + self._model = CrossEncoder(model_id) + print("Reranker 로딩 완료") + + def rerank(self, query: str, docs: list[Document], top_k: int) -> list[Document]: + if not docs: + return docs + pairs = [(query, doc.page_content) for doc in docs] + scores = self._model.predict(pairs) + ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True) + return [doc for _, doc in ranked[:top_k]] diff --git a/services/rag/retriever_service.py b/services/rag/retriever_service.py index 7c5958f..7ce441b 100644 --- a/services/rag/retriever_service.py +++ b/services/rag/retriever_service.py @@ -13,6 +13,8 @@ class RetrieverService: qdrant_url: str, collection_name: str, top_k: int, + reranker=None, + rerank_fetch_k: int = 10, ): self._client = QdrantClient(url=qdrant_url) self._collection_name = collection_name @@ -22,12 +24,18 @@ class RetrieverService: embedding=embeddings, ) self._top_k = top_k + self._reranker = reranker + self._rerank_fetch_k = rerank_fetch_k def as_retriever(self): return self._store.as_retriever(search_kwargs={"k": self._top_k}) def search(self, query: str) -> list[Document]: - return self._store.similarity_search(query, k=self._top_k) + fetch_k = self._rerank_fetch_k if self._reranker else self._top_k + docs = self._store.similarity_search(query, k=fetch_k) + if self._reranker: + docs = self._reranker.rerank(query, docs, top_k=self._top_k) + return docs def list_documents(self) -> list[str]: """Qdrant에 저장된 고유 파일 경로 목록을 반환한다."""