Compare commits
3 Commits
06bcdb03ac
...
145b0cc96f
| Author | SHA1 | Date | |
|---|---|---|---|
| 145b0cc96f | |||
| e1d7e9cc21 | |||
| b4b628ab78 |
@@ -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 파일을 업로드하면 율봇이 내용을 참고해 답변합니다.")
|
||||
|
||||
@@ -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
|
||||
@@ -54,6 +60,13 @@ class Config(BaseSettings):
|
||||
항상 쉽고 친근한 말투로 설명하고, 전문 용어는 풀어서 설명합니다.
|
||||
의학적 진단이나 법적 판단이 필요한 경우에는 반드시 전문가 상담을 권유합니다.
|
||||
|
||||
## 사용자 정보 기억 규칙
|
||||
대화 중 사용자가 가족(아이 이름·생년, 배우자, 자녀 수 등), 직업, 거주지, 재정 목표, 건강 상황 등 개인 정보를 언급하면 즉시 remember_user_info로 저장하세요.
|
||||
- 아이 나이는 생년월일 전체를 저장합니다. (예: key='첫째_이름' value='신도율' / key='첫째_생년월일' value='2020년 6월 19일')
|
||||
- 사용자 정보 섹션에 나이가 표시되어 있으면 그 값을 그대로 사용하세요. 직접 계산하지 마세요.
|
||||
- 나이를 직접 계산해야 할 경우에는 반드시 get_current_date 도구를 먼저 호출하여 오늘 날짜를 확인하세요.
|
||||
- 나이는 항상 한국 나이와 만 나이를 함께 알려주세요. (한국 나이 = 현재 연도 - 출생 연도 + 1 / 만 나이 = 생일이 지났으면 현재 연도 - 출생 연도, 생일이 안 지났으면 -1)
|
||||
|
||||
## 문서 검색 규칙
|
||||
육아·금융 관련 질문이라면 자신의 학습 지식으로 직접 답하지 말고, 반드시 search_documents 도구를 먼저 호출하세요.
|
||||
검색 결과가 없거나 관련 문서가 등록되어 있지 않은 경우에만 학습 지식을 보조적으로 활용합니다."""
|
||||
|
||||
@@ -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
|
||||
|
||||
+257
-10
@@ -1,17 +1,43 @@
|
||||
# 율봇 개발 로드맵
|
||||
|
||||
## 현재 구현 상태 (Phase 1~7 완료)
|
||||
## 현재 구현 상태
|
||||
|
||||
| 영역 | 현황 |
|
||||
|------|------|
|
||||
| LLM | Qwen2.5-7B-Instruct-4bit (MLX, Apple Silicon) |
|
||||
| LLM | Qwen3-8B-4bit (MLX, Apple Silicon) |
|
||||
| Agent | LangGraph ReAct + Tool Calling + Thinking 모드 |
|
||||
| RAG | Qdrant + BAAI/bge-m3 임베딩 |
|
||||
| Tools | `search_documents`, `get_current_date`, `web_search`, `remember_user_info`, `recall_user_info` (5개) |
|
||||
| UI | Gradio Web UI (`app.py`) + CLI (`main.py`) |
|
||||
| Memory | LangGraph MemorySaver (세션 내) + MySQL (대화 영구 저장) + `td_user_profile` (장기 사용자 메모리) |
|
||||
| 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 트레이싱 |
|
||||
| Streaming | 비동기 토큰 스트리밍 + `<think>` 블록 파싱 |
|
||||
| Tracing | LangSmith 트레이싱 설정 완료 (`.env`에서 활성화 가능) |
|
||||
| History Compact | 대화 20턴 초과 시 오래된 절반을 LLM으로 자동 요약 (`CompactService`) |
|
||||
| 나이 계산 | 시스템 프롬프트에 오늘 날짜 주입 + 한국 나이/만 나이 자동 계산 |
|
||||
|
||||
---
|
||||
|
||||
## 버그 수정 현황
|
||||
|
||||
### ✅ 버그 1 — RAG 중복 수집 (수정 완료)
|
||||
`IngestionService._delete_by_source()`를 구현해 같은 파일 경로로 저장된 기존 청크를 `ingest()` 시작 시 삭제한다.
|
||||
|
||||
### ✅ 버그 2 — LangGraph MemorySaver와 MySQL 이력 미연동 (수정 완료)
|
||||
`AgentService.__init__`에서 MySQL에 저장된 최근 10턴을 `_pending_history`로 불러온 뒤, 첫 `stream_response()` 호출 시 LangGraph 초기 메시지로 주입한다.
|
||||
|
||||
### ✅ 버그 3 — 단일 사용자 전제 (수정 완료)
|
||||
DB 스키마(`td_conversations.user_id`, `td_user_profile.user_id`)는 `_migrate_schema`로 자동 마이그레이션. `AgentService`에 `user_id` 파라미터 추가, 모든 Repository 호출에 전파. Gradio에 사용자 선택 드롭다운(아록/근혜/도율/하율) 추가 및 사용자별 에이전트 캐시 구현.
|
||||
|
||||
### ✅ 버그 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` 로컬 변수 사용)
|
||||
|
||||
---
|
||||
|
||||
@@ -47,10 +73,231 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — 멀티모달 이미지 이해 ★☆☆
|
||||
## ✅ Phase 9 — 문서 관리
|
||||
|
||||
**배경**: 이유식 사진 → "이 재료로 만들 수 있는 이유식은?", 금융 서류 사진 → 내용 분석 등 이미지 기반 질문 처리.
|
||||
- `IngestionService._delete_by_source()` — 파일 경로 기반 중복 청크 삭제
|
||||
- `RetrieverService.list_documents()` — Qdrant scroll로 고유 source 목록 반환
|
||||
- `RetrieverService.delete_document(source)` — source 기준 청크 전체 삭제
|
||||
- Gradio "문서 관리" 탭 — 목록 테이블 + 경로 입력 삭제 버튼 + 앱 로드 시 자동 새로고침
|
||||
|
||||
**제약**: Qwen2.5-7B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 모델 교체 필요.
|
||||
---
|
||||
|
||||
## ✅ Phase 10 — 멀티유저 지원
|
||||
|
||||
Bug 3 수정 및 Phase 9 작업과 함께 완전 구현됨.
|
||||
|
||||
- DB 마이그레이션: `mysql_service._migrate_schema()`가 `td_conversations`, `td_user_profile` 양쪽에 `user_id` 컬럼 자동 추가
|
||||
- `ConversationRepository`: `create_conversation(user_id)` / `get_latest_conversation_id(user_id)` — user_id 기반 격리
|
||||
- `AgentService`: `user_id` 파라미터 추가, 모든 프로필·대화 조회에 전파
|
||||
- `make_memory_tools(profile_repo, user_id)`: remember/recall 도구가 올바른 사용자 데이터만 접근
|
||||
- Gradio: 사용자 선택 드롭다운(아록/근혜/도율/하율, 기본값 아록) + `_agent_cache` 사전으로 사용자별 에이전트 분리
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 11 — 대화 이력 복원
|
||||
|
||||
버그 2와 함께 해결됨. `AgentService` 초기화 시 MySQL에서 최근 10턴을 `_pending_history`에 로드 → 첫 메시지와 함께 LangGraph에 주입.
|
||||
|
||||
```python
|
||||
turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
|
||||
# → HumanMessage / AIMessage 변환 후 _pending_history에 저장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 12 — 답변 피드백 & 품질 개선
|
||||
|
||||
**배경**: 에이전트가 잘못된 답변을 해도 피드백 루프가 없어 개선이 어려움.
|
||||
|
||||
**구현 내용**:
|
||||
- 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 품질 향상 ★★★ (완료)
|
||||
|
||||
**배경**: 고정 크기 청킹 + 벡터 유사도 검색만으로는 관련 없는 청크가 섞일 수 있음.
|
||||
|
||||
**✅ Semantic Chunker — 완료**
|
||||
|
||||
커스텀 `_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 저장 문서는 재등록해야 새 청킹 방식이 적용됨.
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 중간 (답변 정확도 향상)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 14 — 음성 인터페이스
|
||||
|
||||
**배경**: 육아 중에는 손이 자유롭지 않아 타이핑이 어려움.
|
||||
|
||||
**구현 내용**:
|
||||
- `openai-whisper` (small 모델) — 마이크 녹음 → 한국어 텍스트 변환, 지연 로딩
|
||||
- macOS `say -v Yuna` — 에이전트 응답을 음성으로 읽어줌 (aiff 파일 경유)
|
||||
- Gradio "대화" 탭 확장 — 마이크 녹음 + "음성→텍스트 변환" 버튼 + "음성으로 답변 읽기" 체크박스 + TTS 오디오 플레이어
|
||||
- LLM/Agent 레이어 변경 없음 — 순수 I/O 어댑터로 구현
|
||||
|
||||
**config.py 추가**: `whisper_model_size = "small"`, `tts_voice = "Yuna"`
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (핵심 사용 시나리오)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 13-B — Reranker ★★☆
|
||||
|
||||
**배경**: 벡터 유사도 검색은 의미적으로 비슷한 청크를 가져오지만, 질문과 실제로 관련 있는 청크를 정확히 가려내지 못하는 경우가 있다. Reranker는 검색 후 순위를 재조정해 LLM에 전달되는 컨텍스트 품질을 높인다.
|
||||
|
||||
**구현 내용**:
|
||||
- `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`로 활성화, 기본 비활성 (첫 실행 시 모델 다운로드)
|
||||
|
||||
| 설정 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `RERANKER_ENABLED` | `false` | `true`로 설정 시 활성화 |
|
||||
| `RERANKER_MODEL_ID` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | 한국어 포함 다국어 모델 (117MB) |
|
||||
| `RERANKER_FETCH_K` | `10` | rerank 전 벡터 검색 후보 수 |
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (관련성 낮은 청크 필터링 → 답변 정확도 향상)
|
||||
|
||||
---
|
||||
|
||||
## 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를 선택할 수 있으면 유연성 확보.
|
||||
|
||||
**구현 방식**: `config.py`에 `model_provider` 추가, `container.py`에서 provider별 chat_model 분기.
|
||||
|
||||
```python
|
||||
model_provider: str = "mlx" # "mlx" | "claude" | "openai"
|
||||
```
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 중간
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Docker 컨테이너화 ★☆☆
|
||||
|
||||
**배경**: 현재 로컬 전용. 가족이나 지인도 쓸 수 있도록 서버 배포 가능한 형태로 패키징.
|
||||
|
||||
**구현 범위**:
|
||||
```
|
||||
docker-compose.yml
|
||||
├── youlbot (Gradio app)
|
||||
├── qdrant
|
||||
└── mysql
|
||||
```
|
||||
|
||||
> 주의: MLX는 Apple Silicon 전용이라 서버 배포 시 Phase 15(모델 선택)이 선행되어야 함.
|
||||
|
||||
**난이도**: 높음 | **임팩트**: 중간
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — 멀티모달 이미지 이해 ★☆☆
|
||||
|
||||
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
|
||||
|
||||
**제약**: Qwen3-14B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 교체 필요.
|
||||
|
||||
**난이도**: 높음 | **임팩트**: 높음 (장기 과제)
|
||||
|
||||
---
|
||||
|
||||
## 추천 진행 순서
|
||||
|
||||
```
|
||||
단기 (1~2주) 중기 (1개월) 장기
|
||||
──────────────────────── ────────────────────── ──────────────────
|
||||
Phase 18 Hybrid Search → Phase 15 (모델선택) → Phase 16 (Docker)
|
||||
Phase 19 Query Rewriting → Phase 20 (RAGAS 평가) → Phase 17 (멀티모달)
|
||||
```
|
||||
|
||||
### 우선순위 매트릭스
|
||||
|
||||
| Phase | 상태 | 난이도 | 임팩트 | 추천 순위 |
|
||||
|-------|------|--------|--------|-----------|
|
||||
| 버그 1 RAG 중복 | ✅ 완료 | — | — | — |
|
||||
| 버그 2 이력 미연동 | ✅ 완료 | — | — | — |
|
||||
| 버그 3 단일 사용자 | ✅ 완료 | — | — | — |
|
||||
| 버그 4 나이 계산 오류 | ✅ 완료 | — | — | — |
|
||||
| 버그 5 thinking 체크박스 무효 | ✅ 완료 | — | — | — |
|
||||
| Phase 4 Web UI | ✅ 완료 | — | — | — |
|
||||
| Phase 5 장기 사용자 메모리 | ✅ 완료 | — | — | — |
|
||||
| Phase 6 웹 검색 | ✅ 완료 | — | — | — |
|
||||
| Phase 7 LangSmith 트레이싱 | ✅ 완료 | — | — | — |
|
||||
| Phase 9 문서 관리 | ✅ 완료 | — | — | — |
|
||||
| Phase 10 멀티유저 | ✅ 완료 | — | — | — |
|
||||
| Phase 11 이력 복원 | ✅ 완료 | — | — | — |
|
||||
| Phase 12 피드백 | ✅ 완료 | — | — | — |
|
||||
| Phase 13 Semantic Chunker | ✅ 완료 | — | — | — |
|
||||
| Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — |
|
||||
| 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순위 |
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
# 율봇 개발 로드맵 2
|
||||
|
||||
## 현재 구현 상태 (Phase 1~11 + Phase 14 완료, 버그 1~3 수정 완료, 모델 업그레이드)
|
||||
|
||||
| 영역 | 현황 |
|
||||
|------|------|
|
||||
| LLM | Qwen3-14B-4bit (MLX, Apple Silicon) |
|
||||
| Agent | LangGraph ReAct + Tool Calling + Thinking 모드 |
|
||||
| RAG | Qdrant + BAAI/bge-m3 임베딩 |
|
||||
| Tools | `search_documents`, `web_search`, `get_current_date`, `remember_user_info`, `recall_user_info` (5개) |
|
||||
| UI | CLI + Gradio Web UI |
|
||||
| Memory | LangGraph MemorySaver (세션 내) + MySQL 대화 저장 + 장기 사용자 프로필 |
|
||||
| Tracing | LangSmith 트레이싱 |
|
||||
| Streaming | 비동기 토큰 스트리밍 + `<think>` 블록 파싱 |
|
||||
| History Compact | 대화 20턴 초과 시 오래된 절반을 LLM으로 자동 요약 (`CompactService`) |
|
||||
|
||||
---
|
||||
|
||||
## 버그 수정 현황
|
||||
|
||||
### ✅ 버그 1 — RAG 중복 수집 (수정 완료)
|
||||
`IngestionService._delete_by_source()`를 구현해 같은 파일 경로로 저장된 기존 청크를 `ingest()` 시작 시 삭제한다.
|
||||
|
||||
### ✅ 버그 2 — LangGraph MemorySaver와 MySQL 이력 미연동 (수정 완료)
|
||||
`AgentService.__init__`에서 MySQL에 저장된 최근 10턴을 `_pending_history`로 불러온 뒤, 첫 `stream_response()` 호출 시 LangGraph 초기 메시지로 주입한다.
|
||||
|
||||
### ✅ 버그 3 — 단일 사용자 전제 (수정 완료)
|
||||
DB 스키마(`td_conversations.user_id`, `td_user_profile.user_id`)는 `_migrate_schema`로 자동 마이그레이션. `AgentService`에 `user_id` 파라미터 추가, 모든 Repository 호출에 전파. Gradio에 사용자 선택 드롭다운(아록/근혜/도율/하율) 추가 및 사용자별 에이전트 캐시 구현.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 9 — 문서 관리 (완료)
|
||||
|
||||
- `IngestionService._delete_by_source()` — 파일 경로 기반 중복 청크 삭제
|
||||
- `RetrieverService.list_documents()` — Qdrant scroll로 고유 source 목록 반환
|
||||
- `RetrieverService.delete_document(source)` — source 기준 청크 전체 삭제
|
||||
- Gradio "문서 관리" 탭 — 목록 테이블 + 경로 입력 삭제 버튼 + 앱 로드 시 자동 새로고침
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 10 — 멀티유저 지원 (완료)
|
||||
|
||||
Bug 3 수정 및 Phase 9 작업과 함께 완전 구현됨.
|
||||
|
||||
- DB 마이그레이션: `mysql_service._migrate_schema()`가 `td_conversations`, `td_user_profile` 양쪽에 `user_id` 컬럼 자동 추가
|
||||
- `ConversationRepository`: `create_conversation(user_id)` / `get_latest_conversation_id(user_id)` — user_id 기반 격리
|
||||
- `AgentService`: `user_id` 파라미터 추가, 모든 프로필·대화 조회에 전파
|
||||
- `make_memory_tools(profile_repo, user_id)`: remember/recall 도구가 올바른 사용자 데이터만 접근
|
||||
- Gradio: 사용자 선택 드롭다운(아록/근혜/도율/하율, 기본값 아록) + `_agent_cache` 사전으로 사용자별 에이전트 분리
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 11 — 대화 이력 복원 (수정 완료)
|
||||
|
||||
버그 2와 함께 해결됨.
|
||||
`AgentService` 초기화 시 MySQL에서 최근 10턴을 `_pending_history`에 로드 → 첫 메시지와 함께 LangGraph에 주입.
|
||||
|
||||
```python
|
||||
# agent_service.py 초기화 (구현됨)
|
||||
turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
|
||||
# → HumanMessage / AIMessage 변환 후 _pending_history에 저장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
);
|
||||
```
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 향상)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — RAG 품질 향상 (Reranker + 청킹 개선) ★★☆ (부분 완료)
|
||||
|
||||
**배경**: 현재 고정 크기 청킹 + 벡터 유사도 검색만으로는 관련 없는 청크가 섞일 수 있음.
|
||||
|
||||
**✅ 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에 전달
|
||||
|
||||
> 기존 Qdrant 저장 문서는 재등록해야 새 청킹 방식이 적용됨.
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 중간 (답변 정확도 향상)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 14 — 음성 인터페이스 (완료)
|
||||
|
||||
**배경**: 육아 중에는 손이 자유롭지 않아 타이핑이 어려움. 음성으로 질문하고 답변을 들을 수 있으면 핵심 사용 시나리오 커버.
|
||||
|
||||
**구현 내용**:
|
||||
- `openai-whisper` (small 모델) — 마이크 녹음 → 한국어 텍스트 변환, 지연 로딩
|
||||
- macOS `say -v Yuna` — 에이전트 응답을 음성으로 읽어줌 (aiff 파일 경유)
|
||||
- Gradio "대화" 탭 확장 — 마이크 녹음 + "음성→텍스트 변환" 버튼 + "음성으로 답변 읽기" 체크박스 + TTS 오디오 플레이어
|
||||
- LLM/Agent 레이어 변경 없음 — 순수 I/O 어댑터로 구현
|
||||
|
||||
```python
|
||||
# app.py — STT
|
||||
def transcribe_audio(filepath: str) -> str:
|
||||
result = whisper.load_model("small").transcribe(filepath, language="ko")
|
||||
return result["text"].strip()
|
||||
|
||||
# app.py — TTS
|
||||
def tts_speak(text: str, voice: str) -> str | None:
|
||||
subprocess.run(["say", "-v", voice, "-o", tmp.name, text], ...)
|
||||
```
|
||||
|
||||
**config.py 추가**: `whisper_model_size = "small"`, `tts_voice = "Yuna"`
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (핵심 사용 시나리오)
|
||||
|
||||
---
|
||||
|
||||
## Phase 15 — 예방접종·건강검진 알림 스케줄러 ★★☆
|
||||
|
||||
**배경**: 아이 생년을 기억하고 있으므로, 예방접종 일정(BCG, DTaP 등)을 자동 계산해 알림을 줄 수 있음. 율봇의 차별화 포인트.
|
||||
|
||||
**구현 방식**:
|
||||
- `td_user_profile`에서 아이 생년 조회 → 예방접종 스케줄 계산 Tool
|
||||
- Gradio "건강 일정" 탭: 달력형 일정 표시
|
||||
- APScheduler로 당일 알림 (또는 Gradio 시작 시 오늘 일정 배너)
|
||||
|
||||
```python
|
||||
@tool
|
||||
def get_vaccination_schedule(birth_year: int, birth_month: int) -> str:
|
||||
"""아이 생년월을 기반으로 예방접종 일정을 계산합니다."""
|
||||
```
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (육아 특화 차별화)
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — 모델 선택 (Claude API / OpenAI 옵션) ★☆☆
|
||||
|
||||
**배경**: 로컬 MLX 모델은 Apple Silicon 전용. 원격 접속 시나리오나 더 높은 품질이 필요할 때 Claude API/OpenAI를 선택할 수 있으면 유연성 확보.
|
||||
|
||||
**구현 방식**: `config.py`에 `model_provider` 추가, `container.py`에서 provider별 chat_model 분기.
|
||||
|
||||
```python
|
||||
model_provider: str = "mlx" # "mlx" | "claude" | "openai"
|
||||
```
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 중간
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Docker 컨테이너화 ★☆☆
|
||||
|
||||
**배경**: 현재 로컬 전용. 가족이나 지인도 쓸 수 있도록 서버 배포 가능한 형태로 패키징.
|
||||
|
||||
**구현 범위**:
|
||||
```
|
||||
docker-compose.yml
|
||||
├── youlbot (Gradio app)
|
||||
├── qdrant
|
||||
└── mysql
|
||||
```
|
||||
|
||||
> 주의: MLX는 Apple Silicon 전용이라 서버 배포 시 Phase 16(모델 선택)이 선행되어야 함.
|
||||
|
||||
**난이도**: 높음 | **임팩트**: 중간
|
||||
|
||||
---
|
||||
|
||||
## Phase 18 — 멀티모달 이미지 이해 ★☆☆
|
||||
|
||||
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
|
||||
|
||||
**제약**: Qwen3-8B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 교체 필요.
|
||||
|
||||
**난이도**: 높음 | **임팩트**: 높음 (장기 과제)
|
||||
|
||||
---
|
||||
|
||||
## 추천 진행 순서
|
||||
|
||||
```
|
||||
단기 (1~2주) 중기 (1개월) 장기
|
||||
──────────────── ────────────────── ──────────────
|
||||
Phase 14 (음성) → Phase 13 (RAG품질) → Phase 17 (Docker)
|
||||
Phase 15 (알림) Phase 16 (모델선택) Phase 18 (멀티모달)
|
||||
Phase 12 (피드백)
|
||||
```
|
||||
|
||||
### 우선순위 매트릭스
|
||||
|
||||
| Phase | 상태 | 난이도 | 임팩트 | 추천 순위 |
|
||||
|-------|------|--------|--------|-----------|
|
||||
| 버그 1 RAG 중복 | ✅ 완료 | — | — | — |
|
||||
| 버그 2 이력 미연동 | ✅ 완료 | — | — | — |
|
||||
| 버그 3 단일 사용자 | ✅ 완료 | — | — | — |
|
||||
| Phase 9 문서 관리 | ✅ 완료 | — | — | — |
|
||||
| Phase 10 멀티유저 | ✅ 완료 | — | — | — |
|
||||
| Phase 11 이력 복원 | ✅ 완료 | — | — | — |
|
||||
| Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — |
|
||||
| Phase 15 예방접종 알림 | 🔲 미완 | 중간 | 높음 | ⭐ 2순위 |
|
||||
| Phase 12 피드백 | 🔲 미완 | 중간 | 중간 | 3순위 |
|
||||
| Phase 13 RAG 품질 (청킹 완료, Reranker 미완) | 🔲 진행 중 | 중간 | 중간 | 4순위 |
|
||||
| Phase 16 모델 선택 | 🔲 미완 | 중간 | 중간 | 5순위 |
|
||||
| Phase 17 Docker | 🔲 미완 | 높음 | 중간 | 6순위 |
|
||||
| Phase 18 멀티모달 | 🔲 미완 | 높음 | 높음 | 7순위 |
|
||||
@@ -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 생성 흐름:
|
||||
<think>사고 내용...</think> → 최종 답변 텍스트
|
||||
↓ ↓
|
||||
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)에 의한 자연 편차로 보인다.
|
||||
체크박스는 표시 여부만 제어하며 모델 동작 자체는 바꾸지 않는다.
|
||||
@@ -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:
|
||||
@@ -74,18 +76,52 @@ class AgentService:
|
||||
llm_with_tools = chat_model.bind_tools(tools)
|
||||
|
||||
async def call_model(state: MessagesState, config: RunnableConfig) -> dict:
|
||||
system_content = self._system_prompt
|
||||
from datetime import date
|
||||
system_content = f"오늘 날짜: {date.today().isoformat()}\n\n" + self._system_prompt
|
||||
if self._profile_repo:
|
||||
profile = self._profile_repo.get_all(self._user_id)
|
||||
if profile:
|
||||
lines = "\n".join(f"- {k}: {v}" for k, v in profile.items())
|
||||
system_content += f"\n\n## 사용자 정보 (이전 대화에서 기억된 내용)\n{lines}"
|
||||
import re
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
current_year = today.year
|
||||
_DATE_KEYS = ("생년월일", "생년", "생일")
|
||||
lines = []
|
||||
for k, v in profile.items():
|
||||
if any(term in k for term in _DATE_KEYS):
|
||||
full_date = re.search(r'(\d{4})[년\-/.]\s*(\d{1,2})[월\-/.]\s*(\d{1,2})', v)
|
||||
year_only = re.search(r'\b(19|20)\d{2}\b', v)
|
||||
age_key = re.sub(r'생년월일|생년|생일', '나이', k)
|
||||
if full_date:
|
||||
by, bm, bd = int(full_date.group(1)), int(full_date.group(2)), int(full_date.group(3))
|
||||
korean_age = current_year - by + 1
|
||||
intl_age = current_year - by - (1 if today < date(current_year, bm, bd) else 0)
|
||||
lines.append(f"- {age_key}: 한국 나이 {korean_age}세, 만 {intl_age}세")
|
||||
elif year_only:
|
||||
by = int(year_only.group())
|
||||
korean_age = current_year - by + 1
|
||||
intl_age = current_year - by
|
||||
lines.append(f"- {age_key}: 한국 나이 {korean_age}세, 만 {intl_age}~{intl_age - 1}세 (생일에 따라 다름)")
|
||||
else:
|
||||
lines.append(f"- {k}: {v}")
|
||||
else:
|
||||
lines.append(f"- {k}: {v}")
|
||||
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:
|
||||
@@ -107,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:
|
||||
@@ -130,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":
|
||||
@@ -150,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"
|
||||
@@ -188,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"
|
||||
@@ -222,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:
|
||||
|
||||
@@ -5,7 +5,7 @@ from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def get_current_date() -> str:
|
||||
"""오늘 날짜를 반환합니다. 날짜·기간 관련 질문에 사용하세요."""
|
||||
"""오늘 날짜를 반환합니다. 나이 계산, 날짜 비교 등 현재 날짜가 필요할 때 반드시 먼저 호출하세요."""
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -48,8 +47,9 @@ def make_memory_tools(profile_repo, user_id: str = "default"):
|
||||
@tool
|
||||
def remember_user_info(key: str, value: str) -> str:
|
||||
"""사용자 정보를 영구 저장합니다. 다음 대화에도 기억해야 할 정보를 저장하세요.
|
||||
- 아이 나이는 반드시 '생년(출생연도)'으로 저장하세요. 나이는 매년 바뀌지만 생년은 영구적입니다.
|
||||
예: key='첫째_이름' value='신도율', key='첫째_생년' value='2020'
|
||||
- 아이 생년월일은 전체 날짜로 저장하세요. 날짜를 모르면 연도만이라도 저장하세요.
|
||||
예: key='첫째_이름' value='신도율', key='첫째_생년월일' value='2020년 6월 19일'
|
||||
연도만 알 경우: key='첫째_생년' value='2020'
|
||||
- 기타 key 예시: 재정_목표, 거주지, 직업, 자녀수"""
|
||||
profile_repo.remember(key, value, user_id=user_id)
|
||||
return f"'{key}' 정보를 기억했습니다: {value}"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = "<think>"
|
||||
CLOSE_THINK = "</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 모델은 <think> 블록을 생성하지 않으므로 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] = []
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]
|
||||
@@ -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에 저장된 고유 파일 경로 목록을 반환한다."""
|
||||
|
||||
Reference in New Issue
Block a user