diff --git a/.env.example b/.env.example index ae0a102..2e61206 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ LANGCHAIN_PROJECT=youlbot # Hybrid Search (Phase 18) — BM25 + Vector (활성화 후 기존 문서 재수집 필요) HYBRID_SEARCH_ENABLED=false SPARSE_MODEL_ID=Qdrant/bm25 + +# Query Rewriting (Phase 19) — search_documents 호출 시 구어체 쿼리를 검색 최적화 쿼리로 변환 +QUERY_REWRITE_ENABLED=false diff --git a/config.py b/config.py index 362bd1a..0809517 100644 --- a/config.py +++ b/config.py @@ -45,6 +45,9 @@ class Config(BaseSettings): # Hybrid Search (Phase 18) — BM25 + Vector hybrid_search_enabled: bool = False sparse_model_id: str = "Qdrant/bm25" # fastembed sparse 모델 (언어 무관 BM25) + + # Query Rewriting (Phase 19) — 구어체 질문을 검색 최적화 쿼리로 변환 + query_rewrite_enabled: bool = False rag_verbose: bool = False rag_show_sources: bool = False langgraph_verbose: bool = False diff --git a/container.py b/container.py index d54e7ef..d787393 100644 --- a/container.py +++ b/container.py @@ -140,6 +140,7 @@ class Container(containers.DeclarativeContainer): rag_show_sources=providers.Callable(lambda c: c.rag_show_sources, config), langgraph_verbose=providers.Callable(lambda c: c.langgraph_verbose, config), think_verbose=providers.Callable(lambda c: c.think_verbose, config), + query_rewrite_enabled=providers.Callable(lambda c: c.query_rewrite_enabled, config), user_profile_repository=user_profile_repository, conversation_repository=conversation_repository, ) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 62914c2..32e3e57 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -204,14 +204,17 @@ turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10) --- -## Phase 19 — Query Rewriting ★☆☆ +## ✅ Phase 19 — Query Rewriting ★☆☆ **배경**: 사용자 구어체 질문("아이가 밥을 안 먹어요")은 벡터 검색에 최적화되어 있지 않다. LLM이 검색 전에 질문을 재작성하면 관련 문서 검색 확률이 높아진다. -**구현 방식**: -- LangGraph에 `query_rewrite` 노드 추가 (agent → query_rewrite → tools 순서) -- 또는 `search_documents` 도구 내부에서 rewrite 후 검색 -- 프롬프트: "다음 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요" +**구현 내용**: +- LangGraph 그래프에 `query_rewrite` 노드 추가 — `agent → query_rewrite → tools` 순서 +- `search_documents` 호출 시에만 작동하는 조건부 라우팅 (`route_after_agent`): 다른 도구 호출이나 tool 없음 케이스는 그대로 통과 +- 구어체 → 키워드 중심 쿼리로 변환 + 대명사·지시어를 구체적 명칭으로 해소 (이전 대화 2턴 컨텍스트 활용) +- `tools_condition` 제거 → 커스텀 `route_after_agent` 함수로 대체 +- 변환 결과를 custom stream 이벤트로 emit → `RAG_VERBOSE=true` 시 `쿼리 최적화: "원본" → "최적화"` 출력 +- `.env` `QUERY_REWRITE_ENABLED=true`로 활성화 **난이도**: 하 | **임팩트**: 중간 (구어체 질문 검색 품질 향상) @@ -277,8 +280,8 @@ docker-compose.yml ``` 단기 (1~2주) 중기 (1개월) 장기 ──────────────────────── ────────────────────── ────────────────── -Phase 19 Query Rewriting → Phase 15 (모델선택) → Phase 16 (Docker) - → Phase 20 (RAGAS 평가) → Phase 17 (멀티모달) +Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 16 (Docker) + → Phase 17 (멀티모달) ``` ### 우선순위 매트릭스 @@ -302,7 +305,7 @@ Phase 19 Query Rewriting → Phase 15 (모델선택) → Phase 16 (Docker) | Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — | | Phase 13-B Reranker | ✅ 완료 | — | — | — | | Phase 18 Hybrid Search | ✅ 완료 | — | — | — | -| Phase 19 Query Rewriting | 🔲 신규 | 하 | 중간 | 3순위 | +| Phase 19 Query Rewriting | ✅ 완료 | — | — | — | | Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 | | Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 5순위 | | Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 6순위 | diff --git a/services/agent/agent_service.py b/services/agent/agent_service.py index 78d0472..d82ddc1 100644 --- a/services/agent/agent_service.py +++ b/services/agent/agent_service.py @@ -7,8 +7,8 @@ from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, Sys 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 +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.prebuilt import ToolNode from services.agent.tools import get_current_date, make_memory_tools, make_retriever_tool, make_search_tool, web_search @@ -28,6 +28,7 @@ class AgentService: rag_show_sources: bool = False, langgraph_verbose: bool = False, think_verbose: bool = False, + query_rewrite_enabled: bool = False, user_profile_repository=None, conversation_repository=None, user_id: str = "default", @@ -37,6 +38,7 @@ class AgentService: self._rag_show_sources = rag_show_sources self._langgraph_verbose = langgraph_verbose self._think_verbose = think_verbose + self._query_rewrite_enabled = query_rewrite_enabled self._source_buffer: list[dict] = [] self._thread_id = "default" self._profile_repo = user_profile_repository @@ -133,11 +135,76 @@ class AgentService: additional_kwargs=extra, )]} + async def query_rewrite_node(state: MessagesState, config: RunnableConfig) -> dict: + last_msg = state["messages"][-1] + if not (hasattr(last_msg, "tool_calls") and last_msg.tool_calls): + return {} + + # 최근 사용자 메시지 2개를 컨텍스트로 활용 (대명사·지시어 해소) + recent_human = [m.content for m in state["messages"][:-1] + if isinstance(m, HumanMessage)][-2:] + ctx = ("\n\n이전 대화 컨텍스트:\n" + "\n".join(f"- {m}" for m in recent_human) + if recent_human else "") + + try: + writer = get_stream_writer() + except Exception: + writer = None + + _rewrite_llm = chat_model.bind(enable_thinking=False) + new_tool_calls = [] + for tc in last_msg.tool_calls: + if tc["name"] == "search_documents": + original = tc["args"].get("query", "") + prompt = ( + f"다음 구어체 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요.{ctx}\n\n" + f"규칙:\n" + f"- 핵심 개념과 전문용어를 포함하세요\n" + f"- 대명사(이것, 그것, 그 논문 등)는 구체적인 명칭으로 교체하세요\n" + f"- 변환된 질문만 한 문장으로 출력하세요. 부가 설명 없이 질문만 출력하세요\n\n" + f"원본 질문: {original}\n최적화된 질문:" + ) + try: + result = await _rewrite_llm.ainvoke([HumanMessage(content=prompt)]) + rewritten = result.content.strip() + except Exception as e: + print(f"[QueryRewrite] 실패: {e}") + rewritten = original + if rewritten and rewritten != original: + new_tool_calls.append({**tc, "args": {**tc["args"], "query": rewritten}}) + if writer: + writer({"__query_rewrite": {"original": original, "rewritten": rewritten}}) + else: + new_tool_calls.append(tc) + else: + new_tool_calls.append(tc) + + if not last_msg.id: + return {} + new_msg = AIMessage( + id=last_msg.id, + content=last_msg.content, + tool_calls=new_tool_calls, + additional_kwargs=last_msg.additional_kwargs, + ) + return {"messages": [new_msg]} + + def route_after_agent(state: MessagesState) -> str: + last_msg = state["messages"][-1] + if not (hasattr(last_msg, "tool_calls") and last_msg.tool_calls): + return END + if self._query_rewrite_enabled: + if any(tc["name"] == "search_documents" for tc in last_msg.tool_calls): + return "query_rewrite" + return "tools" + builder = StateGraph(MessagesState) builder.add_node("agent", call_model) + builder.add_node("query_rewrite", query_rewrite_node) builder.add_node("tools", ToolNode(tools)) builder.add_edge(START, "agent") - builder.add_conditional_edges("agent", tools_condition) + builder.add_conditional_edges("agent", route_after_agent) + builder.add_edge("query_rewrite", "tools") builder.add_edge("tools", "agent") self._agent = builder.compile(checkpointer=MemorySaver()) @@ -176,8 +243,13 @@ class AgentService: ): mode, data = stream_event - # ── custom 이벤트 — call_model writer가 emit한 thinking 토큰 ── + # ── custom 이벤트 ──────────────────────────────────────────── if mode == "custom": + if isinstance(data, dict) and "__query_rewrite" in data: + info = data["__query_rewrite"] + if lg or self._rag_verbose: + yield f'\n쿼리 최적화: "{info["original"]}" → "{info["rewritten"]}"\n' + continue if isinstance(data, dict) and "__thinking" in data: # thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신 if "agent" != prev_node: @@ -209,12 +281,13 @@ class AgentService: thinking_open = False content_started = False if lg: + elapsed = time.perf_counter() - start_time if node == "agent": - elapsed = time.perf_counter() - start_time label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중" yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n" + elif node == "query_rewrite": + yield f"\n[LangGraph → query_rewrite: 쿼리 최적화 중] ({elapsed:.2f}s)\n" elif node == "tools": - elapsed = time.perf_counter() - start_time yield f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n" prev_node = node