Implement Phase 12 feedback, Phase 13 Semantic Chunker, Phase 13-B Reranker, Bug 5 thinking fix
- Phase 12: FeedbackRepository + td_feedback 테이블, Gradio 👍/👎 이벤트, run_id 추적, LangSmith create_feedback() 연동 - Phase 13: 커스텀 _SemanticSplitter 제거 → langchain_experimental.SemanticChunker 교체, buffer_size/threshold_type 환경변수 적용 - Phase 13-B: RerankService (Cross-Encoder), RetrieverService.search()에 reranker 통합, tools.py as_retriever() → search() 전환 - Bug 5: mlx_chat_model enable_thinking 런타임 오버라이드, agent_service stream_mode=["messages","custom"] 이중 스트림, thinking 토큰 custom 이벤트로 emit - ROADMAP: LLM 모델명 8B 반영, RAG에 Reranker 추가, 추천 진행 순서 갱신 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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