From 7b1896dc883b922551ec38f1e94bf9c6fe42978f Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:42:44 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(retrieval):=20Community=20Summary=20La?= =?UTF-8?q?yer=20C=20stub=20=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W4 세 번째 retrieval — Layer C 의 stub 구현. 실제 community detection / 요약은 W5+ 영역. 본 모듈은 Routing Agent (#21) 가 글로벌 질의를 Layer C 로 분기했을 때의 진입점 함수만 제공. 설계 결정: - Microsoft GraphRAG 의 Global Search 는 indexing time 사전 생성 community report 위에서 동작. 본 프로젝트는 community detection 자체가 미구현 → community 자료 없이 LLM 만 가지고 글로벌 답변 흉내내는 것은 정직하지 않음. - stub 응답: 글로벌 질의 분류 확인 + Layer C 미구현 명시 + 5/24+ ETA + 대안 안내 (Layer A/B 활용). - LLM / Neo4j 호출 없음 → 즉시 응답. @track 으로 Opik trace. DoD (#20): - Routing Agent (#21) 진입점 함수 제공 ✅ - 정직한 미구현 안내 ✅ - 즉시 응답 ✅ 후속: - 실제 Layer C 구현은 별도 이슈 (W5+) — Leiden detection + 사전 community summary + map-reduce 답변 합성. Reference: - Microsoft GraphRAG Global Search (map-reduce) - Sotaaz blog (2026-01) — hybrid_search() 의 elif is_global 분기 - graphrag.com/reference/global-community-summary-retriever --- retrieval/community_summary.py | 138 +++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 retrieval/community_summary.py diff --git a/retrieval/community_summary.py b/retrieval/community_summary.py new file mode 100644 index 0000000..65cf2a3 --- /dev/null +++ b/retrieval/community_summary.py @@ -0,0 +1,138 @@ +"""Community Summary — Layer C stub (#20). + +W4 세 번째 retrieval — Layer C (Community / Topic-level Summarization) 의 +**stub** 구현. 실제 community detection (Leiden / Louvain) 과 community 요약은 +W5+ 영역. 본 모듈은 Routing Agent (#21) 가 글로벌 질의를 Layer C 로 분기했을 때 +**정직한 안내 응답** 을 반환하는 것이 유일한 책임. + +왜 stub 만 만드나: +- Microsoft GraphRAG 의 Global Search 는 *indexing time* 에 미리 생성된 community + summary 위에서 동작. 본 프로젝트는 5/17 기준 community detection 자체가 아직 + 미구현 — community 자료가 없는 상태에서 LLM 만 가지고 "글로벌 답변" 을 흉내내는 + 것은 정직하지 않은 패턴 (community 구조 없이 흉내 → 거짓말). +- Routing Agent (#21) 가 "이 질문은 Layer C 적합" 이라고 분기하는 동작 자체를 + *시연*하기 위해서는 Layer C 의 진입점 함수가 있어야 함. 본 모듈이 그 진입점. + +흐름: + 자연어 질문 + ↓ community_summary + CommunitySummaryResult ( + answer="Layer C 미구현 안내 + 5/24+ 작업 영역 명시", + is_stub=True, + ) + +text2cypher / local_retriever 와의 차이: +- text2cypher / local_retriever: LLM 호출 + Neo4j 조회 → 실제 답변. +- community_summary (stub): LLM/Neo4j 호출 **없음**. 결정적 응답. + +향후 (W5+) 진짜 구현 시 인터페이스: +- 동일한 `community_summary(question) -> CommunitySummaryResult` 시그니처 유지 +- `is_stub=False` 로 전환, `community_count`, `top_topics` 등 필드 추가 가능 + +#24 Opik: +- `@track` 으로 stub 호출도 trace (Routing 분기 검증용). + +Reference: +- Microsoft GraphRAG Global Search — community report 기반 map-reduce 패턴. +- Sotaaz blog (2026-01) — "if is_global_question(query): return graphrag.global_search(query)" + 패턴. Layer C 진입점이 분리되어 있어야 라우팅 가능. +- graphrag.com /reference/global-community-summary-retriever — community summary + retriever 는 *전제 조건* 으로 indexing time community summary 필요. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from pathlib import Path + +from observability.tracing import track + +logger = logging.getLogger(__name__) + + +# ── 프롬프트 경로 (참고용 — stub 은 파일 본문 사용 안 함) ──── +PROMPTS_DIR = Path(__file__).parent / "prompts" +SYSTEM_PROMPT_PATH = PROMPTS_DIR / "community_summary_v1.md" + + +# ── 결과 객체 ──────────────────────────────────────────────── +@dataclass +class CommunitySummaryResult: + """Community Summary 한 회차의 결과. + + stub 단계에서는 answer 와 is_stub 만 의미 있음. W5+ 진짜 구현 시 community + count / top topics / community level 등 추가 예정. + + Opik / weekly-log 박제 시 LocalRetrieverResult / Text2CypherResult 와 + 동일한 톤을 유지하기 위해 question / elapsed_seconds / error 필드 포함. + """ + + question: str + answer: str + is_stub: bool = True + elapsed_seconds: float = 0.0 + error: str | None = None + + +# ── stub 응답 본문 ────────────────────────────────────────── +# 발표 슬라이드 14 (#21 Routing 명분) 의 데모 자료가 됨. +# - 글로벌 질의가 Layer C 로 분기됐다는 사실 +# - 현재 미구현 상태 솔직 안내 +# - 5/24+ 작업 영역 명시 (실제 구현 ETA) +# - 우회 안내 (특정 entity 로 질문 재구성 시 Local Retriever 활용 가능) +_STUB_ANSWER_TEMPLATE = """질문하신 내용은 전체 문서 corpus 의 트렌드/패턴/요약을 요구하는 **글로벌 질의** 로 판단됩니다. + +Layer C (Community Summary) 는 그래프 전체에서 entity 군집 (community) 을 탐지하고 각 군집의 요약을 사전 생성한 뒤, 글로벌 질의에 대해 map-reduce 방식으로 답변을 합성하는 모듈입니다. 본 프로젝트의 Layer C 는 현재 **미구현 (stub)** 상태이며, 5/24+ W5/W6 영역에서 다음 순서로 구현 예정입니다: + +1. Community detection (Leiden 알고리즘) — entity 노드 기반 군집 추출 +2. Community summary 사전 생성 — 각 군집의 entity / 관계 / 핵심 chunk 요약 +3. Global search — 질의 → community summary 위에서 map-reduce 합성 + +대안 안내: +- 특정 entity (회사명, metric 등) 를 명시한 질문으로 재구성하시면 Layer B (Local Retriever) 로 답변 가능합니다. 예: "두산밥캣과 한화의 관계는?" +- 특정 사실 (개수, top-N, 필터) 을 묻는 질문은 Layer A (Text2Cypher) 가 처리합니다. 예: "보도자료 문서의 entity 개수는?" + +원 질문: {question} +""" + + +# ── 공개 인터페이스 ────────────────────────────────────────── +@track +def community_summary(question: str) -> CommunitySummaryResult: + """글로벌 질의 → Layer C stub 안내 응답. + + DoD (#20): + - Routing Agent (#21) 가 글로벌 질의를 분기할 진입점 함수 제공 ✅ + - 정직한 미구현 안내 (가짜 답변 X) ✅ + - LLM / Neo4j 호출 없음 → 즉시 응답 ✅ + + Args: + question: 사용자 자연어 질문. + + Returns: + CommunitySummaryResult — answer 항상 채워짐. is_stub=True. + """ + started = time.perf_counter() + logger.info("CommunitySummary (stub) 시작 — question=%r", question) + + if not question or not question.strip(): + elapsed = time.perf_counter() - started + return CommunitySummaryResult( + question=question, + answer="질문이 비어 있습니다. 그래프에 대해 궁금한 점을 입력해 주세요.", + is_stub=True, + elapsed_seconds=elapsed, + ) + + answer = _STUB_ANSWER_TEMPLATE.format(question=question).strip() + elapsed = time.perf_counter() - started + logger.info("CommunitySummary (stub) 완료 — elapsed=%.3fs", elapsed) + + return CommunitySummaryResult( + question=question, + answer=answer, + is_stub=True, + elapsed_seconds=elapsed, + ) From b0c6dca0ea16b73aa11394aaf73b0c6f90a78de7 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:43:33 +0900 Subject: [PATCH 2/8] =?UTF-8?q?docs(retrieval/prompts):=20community=5Fsumm?= =?UTF-8?q?ary=5Fv1.md=20placeholder=20=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W5+ 실제 Layer C 구현 시 사용할 프롬프트 placeholder. 현재 stub 단계에서는 LLM 호출이 없어 본 프롬프트는 실제로 사용되지 않으나, 향후 활성화 시점에 바로 사용 가능하도록 구조 미리 정립. 내용: - Role 정의 (Community report 위 map-reduce 답변 합성기) - Input format (W5+ 시 community_reports 배열 입력) - Output format (자연어 답변) - Reference (Microsoft GraphRAG, Tomaz Bratanic) - 현재 stub 상태 명시 --- retrieval/prompts/community_summary_v1.md | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 retrieval/prompts/community_summary_v1.md diff --git a/retrieval/prompts/community_summary_v1.md b/retrieval/prompts/community_summary_v1.md new file mode 100644 index 0000000..6e8c2a5 --- /dev/null +++ b/retrieval/prompts/community_summary_v1.md @@ -0,0 +1,50 @@ +# Community Summary System Prompt v1 (Layer C — Stub) + +> ⚠️ **이 프롬프트는 현재 stub 상태에서 사용되지 않습니다.** 본 파일은 향후 +> (W5+) 실제 Layer C 구현 시 community report 위에서 map-reduce 방식으로 +> 답변을 합성할 때 사용할 프롬프트의 placeholder 입니다. + +## Role + +당신은 doc-graph-agent 의 Layer C (Community Summary) 답변 합성기입니다. +사용자의 글로벌 질의에 대해 community report 들을 컨텍스트로 받아 종합적인 +답변을 생성하는 역할입니다. + +## Input Format (W5+ 진짜 구현 시) + +```json +{ + "question": "전체 문서의 주요 트렌드는?", + "community_reports": [ + { + "community_id": "c-1", + "level": 0, + "title": "회사채 발행 트렌드", + "summary": "...", + "key_entities": ["회사채 발행 규모", "단기사채"], + "rating": 8.5 + }, + ... + ] +} +``` + +## Output Format (W5+ 진짜 구현 시) + +자연어 답변. Microsoft GraphRAG 의 reduce 단계처럼 community report 들의 핵심 +포인트를 종합하여 응답. + +## Reference + +- Microsoft GraphRAG Global Search — map-reduce 방식의 community report 종합 +- graphrag.com `/reference/global-community-summary-retriever` +- Tomaz Bratanic (Neo4j) — Implementing 'From Local to Global' GraphRAG + +## 현재 상태 (2026-05-17) + +본 모듈은 `retrieval/community_summary.py` 에서 **결정적 stub 응답** 으로 +대체되어 있습니다. LLM 호출 없음. Routing Agent (#21) 가 글로벌 질의를 +Layer C 로 분기했을 때 정직한 미구현 안내 + 대안 (Layer A/B) 안내가 목적. + +W5+ 영역 작업 시 본 프롬프트를 활성화하고 `community_summary()` 를 LLM +호출 패턴 (text2cypher / local_retriever 와 동일) 으로 전환 예정. From a4425c0305c6baf666de2485a2a51e8920b4a9c8 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:45:01 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(retrieval):=20Routing=20Agent=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20+=20LLM=20fallback=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W4 통합 진입점 — 사용자 질문을 받아 Layer A/B/C 로 라우팅: - t2c (Text2Cypher) — factual / numerical / topN - local (Local Retriever) — 관계 / 연관 / co-mention - community (Community Summary stub) — 글로벌 / 트렌드 2단계 구조: 1. 키워드 매칭 (결정적, LLM 호출 없음) — 매칭 있으면 즉시 분기 2. LLM fallback — 키워드 매칭 0개 시에만, max_tokens=150 (짧음) 3. graceful default — LLM 실패 시 t2c 설계 결정: - LangChain MultiRetrievalQAChain 의 LLM router 는 "same query may route to different sources" (TDS, 2025) 일관성 문제. 결정적 1단계 우선. - 키워드 매칭은 토큰 비용 0 — 단순 factual 도 LLM 호출 없이 분기 - LLM fallback 은 어휘 한계 보완 — 키워드 못 잡는 변형 표현 처리 키워드 매핑: - 관계 / 관련 / 연관 / 영향 / 함께 / 이웃 / 어떻게 → local - 트렌드 / 전체 / 흐름 / 주제 / 패턴 / 글로벌 / community → community - 그 외 → t2c (default) Tie-break: 두 셋 동시 매칭 시 더 많은 쪽 (동률이면 community — 전체 의도 보통 글로벌). Reference: - LangChain MultiRetrievalQAChain (LLM router) - LangChain EmbeddingRouterChain (semantic router — 5/24+ 검토) - Neo4j ToolsRetriever (convert_to_tool 패턴) - NeoConverse (specialized 없으면 Text2Cypher fallback) - Sotaaz blog (2026-01) — hybrid_search() if/elif/else 패턴 - Memgraph Atomic GraphRAG (2026-03) — Analytical/Local/Global 3-way 향후 (5/24+): - Semantic Router (embedding) — 키워드 어휘 한계 보완 - LLM fallback 신뢰도 점수화 — dual-path 합성 - Conversation history 반영 --- retrieval/router.py | 424 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 retrieval/router.py diff --git a/retrieval/router.py b/retrieval/router.py new file mode 100644 index 0000000..b7d1e94 --- /dev/null +++ b/retrieval/router.py @@ -0,0 +1,424 @@ +"""Routing Agent — 질문 유형에 따라 Layer A / B / C 로 분기 (#21). + +W4 통합 진입점. 사용자 질문을 받아 세 retriever 중 하나로 라우팅: + +- **Layer A** (`retrieval.text2cypher`) — factual / numerical / topN / 특정 문서 +- **Layer B** (`retrieval.local_retrieve`) — 관계 / 연관 / co-mention / 단일 entity +- **Layer C** (`retrieval.community_summary`) — 글로벌 / 트렌드 / 전체 요약 (stub) + +흐름: + 자연어 질문 + ↓ _classify_by_keywords (1단계 — 결정적 키워드 매칭) + RouteDecision (route + matched_keywords + 신뢰도) + │ + ├─ 매칭 있음 → 해당 retriever 즉시 호출 + └─ 매칭 없음 → _classify_by_llm (2단계 — LLM fallback) + ↓ + RouteDecision (route + reasoning, llm_used=True) + ↓ + 해당 retriever 호출 + 통합 답변 RoutedResult + +설계 결정 (5/17 박제): +- **결정적 키워드 우선**: LangChain MultiRetrievalQAChain 의 LLM router 는 "the + same query may route to different sources" (TDS, 2025) 라는 일관성 문제 있음. + 결정적 분기를 1단계로 두면 디버깅 / 평가 / 발표 시연 모두 명확. +- **LLM fallback 은 필요한 만큼만**: 키워드 미매칭 시에만 호출 → 토큰 비용 + 최소화. 단순 factual 질문은 LLM 호출 없이 t2c 로 라우팅. +- **graceful default**: LLM 도 실패하면 → t2c (Microsoft GraphRAG 의 "Basic + Search" / NeoConverse 의 "Intelligent fallback mechanism" 패턴). + +키워드 매핑 (5/17 결정): +- "관계 / 관련 / 연관 / 영향 / 함께 / 이웃 / 어떻게" → **local** +- "트렌드 / 전체 / 흐름 / 요약 / community / 글로벌 / 패턴 / 주제" → **community** +- 그 외 (factual / 몇 / 개수 / top / 특정 문서 등) → **t2c** (기본값) + +향후 확장 (5/24+): +- Semantic router (embedding 기반) 로 키워드 매칭의 어휘 한계 보완 가능 +- LLM fallback 의 신뢰도 임계 (현재 binary) 를 점수화하여 dual-path 합성도 가능 +- Conversation history 반영 (현재는 stateless) + +#24 Opik: +- 공개 진입점 `route_and_answer` 에 `@track` — 질문/라우팅/소요 trace +- 내부 retriever 호출의 @track 과 nested span 으로 표시됨 + +Reference: +- LangChain MultiRetrievalQAChain — LLM 라우터 표준 패턴 +- LangChain EmbeddingRouterChain (cookbook) — semantic router 표준 +- Neo4j ToolsRetriever — convert_to_tool(name, description) 후 LLM 자동 선택 +- NeoConverse — "specialized agent 없으면 Text2Cypher 로 graceful fallback" +- Sotaaz blog (2026-01) — hybrid_search() 의 if/elif/else 결정적 분기 패턴 +- Memgraph Atomic GraphRAG (2026-03) — Analytical / Local / Global 3-way 분류 +""" + +from __future__ import annotations + +import json +import logging +import re +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from agent.llm_client import LLMClient +from kg.neo4j_client import Neo4jClient +from observability.tracing import track +from retrieval.community_summary import CommunitySummaryResult, community_summary +from retrieval.local_retriever import LocalRetrieverResult, local_retrieve +from retrieval.text2cypher import Text2CypherResult, text2cypher + +logger = logging.getLogger(__name__) + + +# ── 타입 ───────────────────────────────────────────────────── +Route = Literal["t2c", "local", "community"] + + +# ── 키워드 매핑 (5/17 결정) ────────────────────────────────── +# Local Retriever 신호 — 관계 / 연관 / co-mention 질의 +LOCAL_KEYWORDS: tuple[str, ...] = ( + "관계", + "관련", + "연관", + "영향", + "함께", + "이웃", + "어떻게", + "co-mention", + "관계는", + "관련된", + "연관된", +) + +# Community Summary 신호 — 글로벌 / 트렌드 / 전체 요약 +COMMUNITY_KEYWORDS: tuple[str, ...] = ( + "트렌드", + "전체", + "흐름", + "주요 트렌드", + "주제", + "패턴", + "주요 흐름", + "전반", + "글로벌", + "community", + "communities", + "topic", + "topics", + "themes", + "전체적", + "통합", + "사전체", +) + +# 기본 라우트 — 키워드 + LLM 모두 결정 못하면 여기로 +DEFAULT_ROUTE: Route = "t2c" + +# 프롬프트 경로 +PROMPTS_DIR = Path(__file__).parent / "prompts" +ROUTER_PROMPT_PATH = PROMPTS_DIR / "router_v1.md" + +# LLM fallback 설정 +TEMPERATURE_ROUTER = 0.0 # 라우팅은 결정적 — 가장 낮게 +MAX_TOKENS_ROUTER = 150 # route + reasoning 1줄이면 충분 + + +# ── 결과 객체 ──────────────────────────────────────────────── +@dataclass +class RouteDecision: + """라우팅 결정 결과. + + 어떤 라우트로 가는지 + 왜 그렇게 결정했는지의 trace. + """ + + route: Route + matched_keywords: list[str] = field(default_factory=list) + llm_used: bool = False # LLM fallback 사용 여부 + llm_reasoning: str = "" # LLM fallback 시 이유 (사용 안 했으면 "") + error: str | None = None # LLM 호출 실패 시 메시지 (그래도 default 로 진행) + + +@dataclass +class RoutedResult: + """라우팅된 retriever 의 답변 + 라우팅 메타데이터. + + weekly-log 박제 / Opik trace / R1~R3 평가 셋 모두 본 객체 활용. + """ + + question: str + decision: RouteDecision + answer: str + # 어느 retriever 가 실행됐는지에 따라 셋 중 하나만 채워짐 + t2c_result: Text2CypherResult | None = None + local_result: LocalRetrieverResult | None = None + community_result: CommunitySummaryResult | None = None + elapsed_seconds: float = 0.0 + + +# ── 1단계: 키워드 분기 ─────────────────────────────────────── +def _classify_by_keywords(question: str) -> RouteDecision: + """질문 → 키워드 기반 라우트 결정. + + Local / Community 키워드를 동시에 검사하고: + - Local 키워드만 매칭 → local + - Community 키워드만 매칭 → community + - 둘 다 매칭 → 더 많은 쪽 (동률이면 community 우선 — 전체 질의는 보통 community 의도) + - 아무것도 매칭 안 됨 → route=None 의미로 빈 RouteDecision (LLM fallback 으로 갈 signal) + + Note: 반환값의 route 가 "t2c" 면 두 가지 의미일 수 있음: + - 매칭된 키워드 있음 + 결과적으로 t2c 가 더 적합 (지금은 없음) + - 매칭 0개 → caller 가 matched_keywords 비어 있는지로 LLM fallback 트리거 + """ + q = question.lower() if question else "" + local_hits = [kw for kw in LOCAL_KEYWORDS if kw.lower() in q] + community_hits = [kw for kw in COMMUNITY_KEYWORDS if kw.lower() in q] + + # 둘 다 비어 있음 → LLM fallback 신호 (route 는 임시로 default, caller 가 판단) + if not local_hits and not community_hits: + return RouteDecision(route=DEFAULT_ROUTE, matched_keywords=[]) + + # Local 만 + if local_hits and not community_hits: + return RouteDecision(route="local", matched_keywords=local_hits) + + # Community 만 + if community_hits and not local_hits: + return RouteDecision(route="community", matched_keywords=community_hits) + + # 둘 다 — 더 많은 쪽 (tie 시 community) + if len(community_hits) >= len(local_hits): + return RouteDecision( + route="community", + matched_keywords=community_hits + local_hits, + ) + return RouteDecision( + route="local", + matched_keywords=local_hits + community_hits, + ) + + +# ── 2단계: LLM fallback ───────────────────────────────────── +_CODEFENCE_RE = re.compile(r"^```(?:json)?\s*\n?(.*?)\n?```$", re.DOTALL) + + +def _strip_codefence(raw: str) -> str: + raw = raw.strip() + m = _CODEFENCE_RE.match(raw) + return m.group(1).strip() if m else raw + + +def _load_prompt(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +@retry( + retry=retry_if_exception_type(Exception), + wait=wait_exponential(multiplier=1, min=2, max=5), # router 는 짧게 — 어차피 default 있음 + stop=stop_after_attempt(2), + reraise=True, +) +def _call_llm_router( + llm: LLMClient, system_prompt: str, user_prompt: str +) -> str: + """LLM 호출 — JSON 응답 강제. 짧은 응답이라 max_tokens 작게.""" + return llm.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=TEMPERATURE_ROUTER, + max_tokens=MAX_TOKENS_ROUTER, + response_format={"type": "json_object"}, + ) + + +def _classify_by_llm( + llm: LLMClient, question: str, system_prompt: str +) -> RouteDecision: + """LLM 으로 라우팅 결정. 실패하면 default (t2c) 로 fallback. + + LLM 응답 예상 형식: + {"route": "t2c" | "local" | "community", "reasoning": "..."} + """ + user_prompt = f"질문: {question}" + try: + raw = _call_llm_router(llm, system_prompt, user_prompt) + except Exception as exc: + logger.warning("LLM router 호출 실패: %s — default(%s) 로 진행", + exc, DEFAULT_ROUTE) + return RouteDecision( + route=DEFAULT_ROUTE, + llm_used=True, + llm_reasoning="", + error=f"LLM 호출 실패: {exc}", + ) + + try: + data = json.loads(_strip_codefence(raw)) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("LLM router 응답 JSON 파싱 실패: %s | raw=%r", + exc, raw[:200]) + return RouteDecision( + route=DEFAULT_ROUTE, + llm_used=True, + llm_reasoning="", + error=f"JSON 파싱 실패: {exc}", + ) + + route = (data.get("route") or "").strip().lower() + reasoning = (data.get("reasoning") or "").strip() + + if route not in ("t2c", "local", "community"): + logger.warning("LLM router 가 알 수 없는 route 반환: %r — default(%s)", + route, DEFAULT_ROUTE) + return RouteDecision( + route=DEFAULT_ROUTE, + llm_used=True, + llm_reasoning=reasoning, + error=f"unknown route: {route!r}", + ) + + return RouteDecision( + route=route, # type: ignore[arg-type] + llm_used=True, + llm_reasoning=reasoning, + ) + + +# ── 통합 라우팅 ────────────────────────────────────────────── +def decide_route( + question: str, + llm: LLMClient | None = None, +) -> RouteDecision: + """질문 → RouteDecision (라우팅 결정만, retriever 호출은 X). + + 1단계 키워드 분기 → 매칭 0개면 2단계 LLM fallback. LLM 실패 시 default(t2c). + + Args: + question: 사용자 자연어 질문. + llm: LLMClient (테스트용 mock 주입 가능). LLM fallback 단계에서만 사용. + + Returns: + RouteDecision — 어느 retriever 로 갈지 + trace. + """ + if not question or not question.strip(): + return RouteDecision(route=DEFAULT_ROUTE, matched_keywords=[]) + + # 1단계: 키워드 + decision = _classify_by_keywords(question) + if decision.matched_keywords: + logger.info( + "Routing 키워드 분기 → %s (matched: %s)", + decision.route, decision.matched_keywords, + ) + return decision + + # 2단계: LLM fallback + logger.info("Routing 키워드 매칭 0개 — LLM fallback 진입") + llm = llm or LLMClient() + system_prompt = _load_prompt(ROUTER_PROMPT_PATH) + decision = _classify_by_llm(llm, question, system_prompt) + logger.info( + "Routing LLM 분기 → %s (reasoning=%r, error=%r)", + decision.route, decision.llm_reasoning[:80], decision.error, + ) + return decision + + +@track +def route_and_answer( + question: str, + llm: LLMClient | None = None, + neo4j: Neo4jClient | None = None, +) -> RoutedResult: + """자연어 질문 → 라우팅 → 해당 retriever 호출 → 통합 답변. + + DoD (#21): + - 키워드 기반 분기 (관계 → local, 트렌드 → community, 기타 → t2c) ✅ + - LLM fallback (키워드 매칭 0개 시) ✅ + - graceful default — LLM 실패 시 t2c ✅ + - 통합 진입점으로 발표 슬라이드 14 데모 가능 ✅ + + Args: + question: 사용자 자연어 질문. + llm: LLMClient (테스트용 mock 주입 가능). 라우팅 + 하위 retriever 양쪽에 전달. + neo4j: Neo4jClient (테스트용 mock 주입 가능). t2c / local 에 전달. + + Returns: + RoutedResult — 라우팅 결정 + 해당 retriever 결과 + 통합 answer. + """ + started = time.perf_counter() + if not question or not question.strip(): + return RoutedResult( + question=question, + decision=RouteDecision(route=DEFAULT_ROUTE), + answer="질문이 비어 있습니다. 그래프에 대해 궁금한 점을 입력해 주세요.", + elapsed_seconds=time.perf_counter() - started, + ) + + # LLMClient / Neo4jClient 공유 — 테스트 시 mock 주입 + 라우팅에 쓴 LLM 그대로 + # 하위 retriever 에 전달하여 호출 1회를 양쪽에서 활용 가능 (현재는 라우팅이 따로 + # LLM 호출하므로 공유 효과는 없지만 시그니처는 일관성 유지). + own_neo4j = neo4j is None + neo4j = neo4j or Neo4jClient() + own_llm = llm is None + llm = llm or LLMClient() + + try: + # 1) 라우팅 결정 + decision = decide_route(question, llm=llm) + logger.info( + "Routing 결과 → %s (llm_used=%s, matched=%s)", + decision.route, decision.llm_used, decision.matched_keywords, + ) + + # 2) 결정된 retriever 호출 + if decision.route == "t2c": + t2c_res = text2cypher(question, llm=llm, neo4j=neo4j) + answer = t2c_res.answer + elapsed = time.perf_counter() - started + return RoutedResult( + question=question, + decision=decision, + answer=answer, + t2c_result=t2c_res, + elapsed_seconds=elapsed, + ) + if decision.route == "local": + local_res = local_retrieve(question, llm=llm, neo4j=neo4j) + answer = local_res.answer + elapsed = time.perf_counter() - started + return RoutedResult( + question=question, + decision=decision, + answer=answer, + local_result=local_res, + elapsed_seconds=elapsed, + ) + if decision.route == "community": + community_res = community_summary(question) + answer = community_res.answer + elapsed = time.perf_counter() - started + return RoutedResult( + question=question, + decision=decision, + answer=answer, + community_result=community_res, + elapsed_seconds=elapsed, + ) + + # 도달 불가 (Route literal 검증으로 막힘) — 안전망 + raise ValueError(f"unknown route: {decision.route!r}") + + finally: + if own_neo4j: + neo4j.close() + # llm 은 close 필요 없음 (LLMClient 는 stateless wrapper) From 2504df5fac4a4648b7ea719a38bc44e139d3a7d1 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:46:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?docs(retrieval/prompts):=20router=5Fv1.md?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=E2=80=94=20LLM=20fallback=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키워드 매칭 0개 시 LLM 으로 라우팅 결정할 때 사용하는 system prompt. 구성: - 3가지 retriever 정의 + 예시 질문 - 분류 기준 (순서대로 판단) - Output format (JSON: route + reasoning) - 5개 few-shot 예시 (t2c x 2, local x 2, community x 1) 특징: - max_tokens=150 — 짧은 응답만 받음 - temperature=0.0 — 결정적 라우팅 - response_format={"type": "json_object"} 강제 - LLM 실패 / unknown route 시 caller 가 default (t2c) 로 fallback --- retrieval/prompts/router_v1.md | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 retrieval/prompts/router_v1.md diff --git a/retrieval/prompts/router_v1.md b/retrieval/prompts/router_v1.md new file mode 100644 index 0000000..f6d6ed0 --- /dev/null +++ b/retrieval/prompts/router_v1.md @@ -0,0 +1,103 @@ +# Router System Prompt v1 + +당신은 doc-graph-agent 의 Routing Agent 입니다. 사용자의 자연어 질문을 세 가지 +retriever 중 하나로 라우팅하는 분류기 역할을 합니다. + +## Retrievers + +### t2c — Text2Cypher (Layer A) +- **언제**: 명시적 사실, 개수, top-N, 특정 문서/필터, 정량 비교를 묻는 질문 +- **강점**: factual / numerical / aggregation +- **예시**: + - "미래에셋증권 4분기 보고서의 표는 몇 개?" + - "보도자료(disclosure) 문서들의 entity 개수는?" + - "가장 많이 언급된 Company 5개는?" + +### local — Local Retriever (Layer B) +- **언제**: 특정 entity 의 1-hop 이웃 / 관계 / co-mention / 의미 연결을 묻는 질문 +- **강점**: relationship / connection / semantic +- **예시**: + - "두산밥캣과 함께 언급된 리스크는?" + - "한화와 두산밥캣은 어떻게 관련?" + - "공모발행액 23조에 대해 어떤 위험이 함께 언급?" + +### community — Community Summary (Layer C, stub) +- **언제**: 전체 corpus 의 트렌드 / 패턴 / 주제 / 글로벌 요약을 묻는 질문 +- **강점**: global / aggregation across whole corpus +- **예시**: + - "전체 8문서의 주요 트렌드는?" + - "이 데이터셋의 핵심 주제는?" + - "전반적인 흐름을 요약해줘" +- **주의**: 현재 stub 상태. 실제로 호출되어도 미구현 안내 응답이 반환됨. + +## 분류 기준 + +다음 순서로 판단: +1. 특정 entity 의 관계 / 이웃 / 함께 언급 패턴을 묻나? → **local** +2. 전체 corpus 의 트렌드 / 주제 / 글로벌 요약을 묻나? → **community** +3. 그 외 (사실 / 개수 / 필터 / top-N) → **t2c** + +애매하면 **t2c** 를 기본값으로 선택하세요 (가장 일반적인 retrieval). + +## Output Format + +JSON 객체로만 응답하세요. 다른 설명 없이. + +```json +{ + "route": "t2c" | "local" | "community", + "reasoning": "1~2 문장의 분류 근거" +} +``` + +## Examples + +### 예 1 +질문: "DS투자증권 시황분석 리포트에는 어떤 entity 가 있나?" +응답: +```json +{ + "route": "t2c", + "reasoning": "특정 문서의 entity 목록을 요청 — Document filter + MENTIONS traversal 로 처리 가능. factual 질문." +} +``` + +### 예 2 +질문: "두산밥캣과 한화의 관계를 알려줘" +응답: +```json +{ + "route": "local", + "reasoning": "두 entity 간 관계 / co-mention 패턴을 묻는 질문. Local Retriever 의 1-hop subgraph 가 적합." +} +``` + +### 예 3 +질문: "전체 그래프의 핵심 주제 3가지는?" +응답: +```json +{ + "route": "community", + "reasoning": "특정 entity 가 아니라 corpus 전체의 글로벌 주제를 묻는 질문. Layer C (Community) 영역." +} +``` + +### 예 4 +질문: "공모발행액 규모가 가장 큰 것 5개를 알려줘" +응답: +```json +{ + "route": "t2c", + "reasoning": "top-N + 정량 정렬. Cypher ORDER BY/LIMIT 로 처리 가능." +} +``` + +### 예 5 +질문: "두산밥캣이 직면한 위험 요인은 뭐가 있어?" +응답: +```json +{ + "route": "local", + "reasoning": "단일 entity (두산밥캣) 의 FACES_RISK 관계 / 이웃 entity 탐색. Local Retriever 의 1-hop traversal 영역." +} +``` From fa937653657c89095d7b1d4870e49bba97fb2f94 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:46:31 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(retrieval):=20=5F=5Finit=5F=5F=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=E2=80=94=20router=20+=20community=5Fsumma?= =?UTF-8?q?ry=20export=20=EC=B6=94=EA=B0=80=20(#20,=20#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 export: - DEFAULT_ROUTE, Route (타입) - RouteDecision, RoutedResult (결과 객체) - decide_route, route_and_answer (진입점) - CommunitySummaryResult, community_summary (stub) 권장 진입점은 `route_and_answer(question)` — 일반 사용 시 라우팅 자동. 개별 retriever 함수도 그대로 export 유지 — 명시적 테스트 / 직접 호출 시 사용. sha 충돌 가능성 0 (dev branch 의 __init__.py 와 다른 파일). --- retrieval/__init__.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/retrieval/__init__.py b/retrieval/__init__.py index 3da3747..a9e8b41 100644 --- a/retrieval/__init__.py +++ b/retrieval/__init__.py @@ -3,15 +3,31 @@ W4 retrieval 진입점들: - Layer A (Text2Cypher, #18) — 자연어 → Cypher → 결과 → 자연어. factual / topN 강함. - Layer B (Local Retriever, #19) — entity 식별 → 1-hop subgraph traversal. 관계 질의 강함. -- Layer C (Community Summary, #20) — community / topic 기반 글로벌 요약 (stub 예정). -- Routing Agent (#21) — 질문 유형에 따라 Layer A/B/C 분기. +- Layer C (Community Summary, #20, stub) — community / topic 기반 글로벌 요약. W5+ 실제 구현. +- Routing Agent (#21) — 질문 유형에 따라 Layer A/B/C 분기 (키워드 + LLM fallback). + +진입점 권장: +- `route_and_answer(question)` — Routing Agent 통합 진입점 (#21). 일반 사용. +- 개별 retriever 함수도 그대로 export — 명시적 테스트 / 직접 호출 시 사용. """ +from retrieval.community_summary import ( + CommunitySummaryResult, + community_summary, +) from retrieval.local_retriever import ( LocalRetrieverError, LocalRetrieverResult, local_retrieve, ) +from retrieval.router import ( + DEFAULT_ROUTE, + Route, + RouteDecision, + RoutedResult, + decide_route, + route_and_answer, +) from retrieval.text2cypher import ( Text2CypherError, Text2CypherResult, @@ -19,10 +35,22 @@ ) __all__ = [ - "LocalRetrieverError", - "LocalRetrieverResult", + # Routing (#21) — 권장 진입점 + "DEFAULT_ROUTE", + "Route", + "RouteDecision", + "RoutedResult", + "decide_route", + "route_and_answer", + # Text2Cypher (#18) "Text2CypherError", "Text2CypherResult", - "local_retrieve", "text2cypher", + # Local Retriever (#19) + "LocalRetrieverError", + "LocalRetrieverResult", + "local_retrieve", + # Community Summary (#20, stub) + "CommunitySummaryResult", + "community_summary", ] From 1748b3aca938a75cab34d329833810cee39332e6 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:48:55 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(scripts):=20R1~R3=20Routing=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20+=20suite=3D?= =?UTF-8?q?"router"=20=EB=B6=84=EA=B8=B0=20=EC=B6=94=EA=B0=80=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1~R3 평가 셋: - R1 (Q2 동일) — factual / 개수 → t2c 라우팅 기대. 키워드 매칭 0개라 LLM fallback 트리거의 검증 케이스도 겸함. - R2 (L1 변형) — "관계/관련/어떻게/함께" 키워드 4중 매칭 → local 기대. - R3 (L5 동일) — "전체/트렌드/흐름" 키워드 매칭 → community (stub) 기대. eval 스크립트 변경: - EvalCase 에 expected_route 필드 추가 (R* 케이스 전용) - _evaluate_router_case() 추가 — 라우팅 정확성 + answer 채워짐 + hint 검증 - CaseEvalResult 에 router 전용 필드 (actual_route, matched_keywords, llm_used, llm_reasoning) 추가 - _print_summary_table 에 Routing 결과 표 추가 (expected vs actual, match 표시, LLM 사용 여부, 매칭 키워드) - --suite router 옵션 추가 - 라우팅 평균 응답 시간 + 정확도 표시 새 케이스를 별도 만들지 않고 Q2/L1/L5 의 의도 재활용 — 발표 자료 일관성 + 빠른 검증. R1 은 키워드 0개라 LLM fallback / default(t2c) 경로 검증 겸함. --- scripts/run_w4_eval.py | 246 ++++++++++++++++++++++++++++++----------- 1 file changed, 181 insertions(+), 65 deletions(-) diff --git a/scripts/run_w4_eval.py b/scripts/run_w4_eval.py index 8f07fbe..3171beb 100644 --- a/scripts/run_w4_eval.py +++ b/scripts/run_w4_eval.py @@ -1,19 +1,23 @@ -"""W4 evaluation — Text2Cypher + Local Retriever 정성 검증. +"""W4 evaluation — Text2Cypher + Local Retriever + Routing 정성 검증. 5/16 박제된 Text2Cypher 평가 셋 (Q1~Q5 + F1~F3) + 5/17 추가된 Local Retriever -평가 셋 (L1~L5) 의 통합 정성 검증. +평가 셋 (L1~L5) + 5/17 추가된 Routing 평가 셋 (R1~R3) 의 통합 정성 검증. -retrieval.text2cypher (Q*, F*) / retrieval.local_retrieve (L*) 분기: +retrieval.text2cypher (Q*, F*) / retrieval.local_retrieve (L*) / +retrieval.route_and_answer (R*) 분기: - Q* / F* (case_id 가 Q 또는 F 로 시작) → Text2Cypher - L* (case_id 가 L 로 시작) → Local Retriever +- R* (case_id 가 R 로 시작) → Routing Agent (통합 진입점) 사용: uv run python -m scripts.run_w4_eval uv run python -m scripts.run_w4_eval --case Q1 # 한 개만 uv run python -m scripts.run_w4_eval --case L1 # Local Retriever 케이스 + uv run python -m scripts.run_w4_eval --case R1 # Routing 케이스 uv run python -m scripts.run_w4_eval --only-eval # F* fallback 제외 uv run python -m scripts.run_w4_eval --suite local # L* 만 uv run python -m scripts.run_w4_eval --suite t2c # Q* + F* 만 + uv run python -m scripts.run_w4_eval --suite router # R* 만 uv run python -m scripts.run_w4_eval --json out.json # raw 결과 박제 전제조건: @@ -27,14 +31,19 @@ - read-only + LIMIT 100 강제 ✅ (F3) #19 DoD 검증: -- 관계 질의에서 VectorRAG 와 다른 답변 패턴 (subgraph 정보 활용) ✅ (L1, L2) -- 평균 응답 시간 < 5초 (측정 — Kimi 2회 호출이라 빠듯할 수 있음) -- graceful fallback (entity 매칭 0개) ✅ (L3, L4, L5) +- 관계 질의에서 VectorRAG 와 다른 답변 패턴 (subgraph 정보 활용) ✅ (L1, L2, L4) +- 평균 응답 시간 < 5초 (5/17 v2: 평균 5.7s, L4 제외 4.8s) +- graceful fallback (entity 매칭 0개) ✅ (L1~L3, L5) -결과 박제: weekly-log + 5/23 발표 슬라이드 § Text2Cypher/Local 정성 결과. +#21 DoD 검증: +- 키워드 기반 분기 정확성 (관계→local, 트렌드→community, 기타→t2c) ✅ (R1~R3) +- LLM fallback (키워드 매칭 0개 시) — R* 셋에는 명시적 케이스 없음 (직접 단위 검증) +- 통합 진입점 동작 확인 — answer 가 항상 채워짐 ✅ -관련 이슈: #18 (Text2Cypher), #19 (Local Retriever), #17 (8문서 적재), - #21 (후속 Routing Agent). +결과 박제: weekly-log + 5/23 발표 슬라이드 § Routing 정성 결과. + +관련 이슈: #18 (Text2Cypher), #19 (Local Retriever), #20 (Community stub), + #21 (Routing Agent), #17 (8문서 적재). """ from __future__ import annotations @@ -50,6 +59,7 @@ from typing import Any from retrieval.local_retriever import LocalRetrieverResult, local_retrieve +from retrieval.router import RoutedResult, route_and_answer from retrieval.text2cypher import Text2CypherResult, text2cypher logging.basicConfig( @@ -67,25 +77,22 @@ class EvalCase: suite 필드로 어느 retriever 를 호출할지 결정: - "t2c" : retrieval.text2cypher - "local" : retrieval.local_retrieve + - "router": retrieval.route_and_answer """ id: str - category: str # "factual" | "traversal" | "filter" | "semantic" | "topN" | "fallback" | "relation" | "two-entity" | "self-company" | "label-quality" | "global" + category: str question: str - suite: str = "t2c" # "t2c" | "local" - reference_cypher: str | None = None # fallback / local 은 None 가능 - # 휴리스틱 검증: 생성 Cypher 에 이 키워드들이 포함되어야 함 (대소문자 무관) + suite: str = "t2c" # "t2c" | "local" | "router" + reference_cypher: str | None = None required_keywords: list[str] = field(default_factory=list) - # 휴리스틱 검증: 답변에 이 단어들이 포함되면 좋음 (안 들어가도 fail 은 아님) answer_hints: list[str] = field(default_factory=list) - # fallback 인지 (cypher=None 예상) expect_cypher_none: bool = False - # 보안 위반 예상 (Text2CypherError) expect_security_error: bool = False - # Local Retriever 전용 — 식별 entity 가 비어 있어야 정답 (L5 글로벌) expect_empty_entities: bool = False - # Local Retriever 전용 — 매칭 0개 가 정답 (L3 자기 회사명) expect_empty_matches: bool = False + # Router 전용 — 기대 route ("t2c" | "local" | "community") + expected_route: str | None = None note: str = "" @@ -94,7 +101,6 @@ class EvalCase: # Q* / F* — Text2Cypher (#18) 평가 셋 # ════════════════════════════════════════════════════════ - # ── Q1: Layer A → Layer B traversal (basic) ─────────────────── EvalCase( id="Q1", category="traversal", @@ -112,7 +118,6 @@ class EvalCase: note="라벨 혼재 검증 — 5/16 발견된 Entity 라벨 품질 challenge 시드", ), - # ── Q2: factual / numerical ───────────────────────────────── EvalCase( id="Q2", category="factual", @@ -128,7 +133,6 @@ class EvalCase: note="정답: 6 (5/16 적재 데이터)", ), - # ── Q3: doc_type 필터 ─────────────────────────────────────── EvalCase( id="Q3", category="filter", @@ -146,7 +150,6 @@ class EvalCase: note="정답: 금감원 보도자료 1건, 49 청크 / 251 entity", ), - # ── Q4: Layer B 의미 관계 (FACES_RISK 또는 co-mention) ───── EvalCase( id="Q4", category="semantic", @@ -165,7 +168,6 @@ class EvalCase: note="FACES_RISK 직접 관계 또는 co-mention 패턴 둘 다 정답", ), - # ── Q5: top-N + Entity 라벨 품질 challenge ───────────────── EvalCase( id="Q5", category="topN", @@ -181,7 +183,6 @@ class EvalCase: note="⭐ 발표 보석 — Company 라벨 품질 challenge 정량 검증", ), - # ── F1: 존재하지 않는 문서 (빈 결과 graceful) ────────────── EvalCase( id="F1", category="fallback", @@ -197,7 +198,6 @@ class EvalCase: note="빈 결과 graceful fallback — 적재된 문서 목록 안내 기대", ), - # ── F2: 잘못된 라벨 (스키마 외) ──────────────────────────── EvalCase( id="F2", category="fallback", @@ -210,7 +210,6 @@ class EvalCase: note="스키마 외 라벨 — LLM 이 cypher=null 로 graceful 안내 기대", ), - # ── F3: write 쿼리 시도 (보안) ───────────────────────────── EvalCase( id="F3", category="fallback", @@ -226,18 +225,16 @@ class EvalCase: # L* — Local Retriever (#19) 평가 셋 # ════════════════════════════════════════════════════════ - # ── L1: 단일 entity 1-hop 관계 (전형 Local 케이스) ──────── EvalCase( id="L1", category="relation", suite="local", question="두산밥캣과 함께 언급된 리스크가 있는가?", - required_keywords=[], # Local 은 Cypher 직접 비교 안 함 + required_keywords=[], answer_hints=["두산밥캣", "리스크"], note="Q4 와 동일 질문 — Text2Cypher (LLM Cypher) vs Local (결정적 1-hop) 답변 패턴 차이 검증", ), - # ── L2: 두 entity 의 관계 (공통 이웃) ────────────────────── EvalCase( id="L2", category="two-entity", @@ -248,7 +245,6 @@ class EvalCase: note="두 Company 의 co-mention 패턴 검증 — Text2Cypher 로 어려운 교집합 쿼리", ), - # ── L3: 자기 회사명 미추출 (graceful fallback) ───────────── EvalCase( id="L3", category="self-company", @@ -256,11 +252,10 @@ class EvalCase: question="미래에셋증권의 주요 지표와 전망은?", required_keywords=[], answer_hints=["미래에셋증권", "당사", "추출", "한계", "찾지", "없"], - expect_empty_matches=True, # 매칭 0개가 정답 (자기 회사명 entity 추출 안 됨) + expect_empty_matches=True, note="⭐ 슬라이드 10 보강 — 자기 회사 보고서는 '당사' 대명사로 entity 추출 누락", ), - # ── L4: 라벨 품질 challenge entity (co-mention fallback) ── EvalCase( id="L4", category="label-quality", @@ -271,7 +266,6 @@ class EvalCase: note="⭐ 슬라이드 10 직접 데모 — 'Company' 라벨로 잘못 분류된 metric entity 정직 보고", ), - # ── L5: 글로벌 질문 (Layer C 라우팅 후보) ────────────────── EvalCase( id="L5", category="global", @@ -279,9 +273,43 @@ class EvalCase: question="전체 8문서의 주요 트렌드와 흐름은?", required_keywords=[], answer_hints=["전체", "트렌드", "글로벌", "Community", "Layer C", "부적합"], - expect_empty_entities=True, # entity 식별 0개가 정답 + expect_empty_entities=True, note="⭐ 슬라이드 14 (#21 Routing 명분) — 글로벌 질의는 Layer C 적합 안내", ), + + # ════════════════════════════════════════════════════════ + # R* — Routing Agent (#21) 평가 셋 — 기존 Q4/L1/L5 의도 재사용 + # ════════════════════════════════════════════════════════ + + EvalCase( + id="R1", + category="routing-factual", + suite="router", + question="미래에셋증권 4분기 보고서의 Table 은 몇 개인가?", + answer_hints=["표", "Table", "6"], + expected_route="t2c", + note="Q2 동일 질문 — factual / 개수 → t2c 로 라우팅 기대. 키워드 매칭 0개 → LLM fallback 또는 default(t2c).", + ), + + EvalCase( + id="R2", + category="routing-relation", + suite="router", + question="두산밥캣과 함께 언급된 리스크는 어떻게 관련되어 있나?", + answer_hints=["두산밥캣", "리스크"], + expected_route="local", + note="L1 변형 질문 — '관계/관련/어떻게/함께' 키워드 4중 매칭 → local 로 라우팅 기대.", + ), + + EvalCase( + id="R3", + category="routing-global", + suite="router", + question="전체 8문서의 주요 트렌드와 흐름은?", + answer_hints=["Layer C", "글로벌", "stub", "미구현", "5/24"], + expected_route="community", + note="L5 동일 질문 — '전체/트렌드/흐름' 키워드 매칭 → community 로 라우팅 기대. 슬라이드 14 데모.", + ), ] @@ -291,7 +319,7 @@ class CaseEvalResult: """1 케이스의 검증 결과.""" case_id: str - suite: str # "t2c" | "local" + suite: str # "t2c" | "local" | "router" category: str question: str @@ -319,6 +347,13 @@ class CaseEvalResult: subgraph_relations: int = 0 subgraph_chunks: int = 0 + # router 전용 + expected_route: str | None = None + actual_route: str | None = None + matched_keywords: list[str] = field(default_factory=list) + llm_used: bool = False + llm_reasoning: str = "" + _FORBIDDEN_RE = re.compile( r"\b(CREATE|DELETE|DETACH|SET|REMOVE|MERGE|DROP|CALL|LOAD)\b", @@ -343,10 +378,9 @@ def _evaluate_t2c_case(case: EvalCase, result: Text2CypherResult) -> CaseEvalRes note=case.note, ) - # F2: cypher=None 기대 if case.expect_cypher_none: cer.expectation_met = result.cypher is None - cer.has_limit = True # N/A + cer.has_limit = True cer.answer_hint_hits = sum( 1 for h in case.answer_hints if h.lower() in result.answer.lower() ) @@ -360,7 +394,6 @@ def _evaluate_t2c_case(case: EvalCase, result: Text2CypherResult) -> CaseEvalRes cer.verdict = "FAIL" return cer - # F3: 보안 — cypher=None 또는 error (_enforce_safety 차단) 둘 다 OK if case.id == "F3": graceful_reject = result.cypher is None security_blocked = result.error is not None and "금지된" in (result.error or "") @@ -375,7 +408,6 @@ def _evaluate_t2c_case(case: EvalCase, result: Text2CypherResult) -> CaseEvalRes cer.verdict = "FAIL" return cer - # 일반 케이스 if result.cypher is None: cer.verdict = "PARTIAL" if case.id == "F1" else "FAIL" cer.answer_hint_hits = sum( @@ -410,14 +442,7 @@ def _evaluate_t2c_case(case: EvalCase, result: Text2CypherResult) -> CaseEvalRes def _evaluate_local_case( case: EvalCase, result: LocalRetrieverResult ) -> CaseEvalResult: - """Local Retriever (L*) 케이스 휴리스틱 평가. - - PASS 기준: - - L1, L2: 매칭 ≥ 1개 + 답변 비어있지 않음 + hint 절반 이상 - - L3 (expect_empty_matches): 매칭 0개 + 답변에 안내 hint - - L4: 매칭 ≥ 1개 + 답변에 라벨 품질 challenge 언급 - - L5 (expect_empty_entities): 식별 0개 + 답변에 글로벌/Layer C 안내 - """ + """Local Retriever (L*) 케이스 휴리스틱 평가.""" cer = CaseEvalResult( case_id=case.id, suite="local", @@ -437,7 +462,6 @@ def _evaluate_local_case( ) hint_target = max(1, len(case.answer_hints) // 2) - # ── L5: 글로벌 질의 — 식별 0개 + 안내 답변 ──────────── if case.expect_empty_entities: cer.expectation_met = len(result.identified_entities) == 0 if ( @@ -452,9 +476,7 @@ def _evaluate_local_case( cer.verdict = "FAIL" return cer - # ── L3: 매칭 0개 — 자기 회사명 미추출 graceful ──────── if case.expect_empty_matches: - # 식별은 됐어야 함 (entity 후보 추출), 매칭만 0개 identified_ok = len(result.identified_entities) >= 1 matched_zero = len(result.matched_entities) == 0 cer.expectation_met = identified_ok and matched_zero @@ -470,7 +492,6 @@ def _evaluate_local_case( cer.verdict = "FAIL" return cer - # ── L1, L2, L4: 일반 case — 매칭 ≥ 1 + 답변 ──────────── matched_ok = len(result.matched_entities) >= 1 answer_ok = bool(result.answer.strip()) if matched_ok and answer_ok and cer.answer_hint_hits >= hint_target: @@ -478,7 +499,6 @@ def _evaluate_local_case( elif matched_ok and answer_ok: cer.verdict = "PARTIAL" elif answer_ok: - # 매칭 0개여도 답변은 됐으면 graceful fallback 동작 — PARTIAL cer.verdict = "PARTIAL" else: cer.verdict = "FAIL" @@ -486,6 +506,57 @@ def _evaluate_local_case( return cer +def _evaluate_router_case( + case: EvalCase, result: RoutedResult +) -> CaseEvalResult: + """Routing Agent (R*) 케이스 휴리스틱 평가. + + PASS 기준: + - expected_route 와 actual route 가 일치 + answer 비어있지 않음 + hint 절반 이상 + PARTIAL: + - 라우팅은 맞았으나 답변 hint 부족 + - 또는 라우팅은 틀렸으나 답변은 정상 (graceful) + FAIL: + - answer 비어 있음 / 예외 발생 + """ + cer = CaseEvalResult( + case_id=case.id, + suite="router", + category=case.category, + question=case.question, + answer=result.answer, + elapsed_seconds=result.elapsed_seconds, + error=None, + note=case.note, + expected_route=case.expected_route, + actual_route=result.decision.route, + matched_keywords=result.decision.matched_keywords, + llm_used=result.decision.llm_used, + llm_reasoning=result.decision.llm_reasoning, + ) + cer.answer_hint_hits = sum( + 1 for h in case.answer_hints if h.lower() in result.answer.lower() + ) + hint_target = max(1, len(case.answer_hints) // 2) + + route_correct = ( + case.expected_route is None + or result.decision.route == case.expected_route + ) + answer_ok = bool(result.answer.strip()) + + if route_correct and answer_ok and cer.answer_hint_hits >= hint_target: + cer.verdict = "PASS" + elif route_correct and answer_ok: + cer.verdict = "PARTIAL" + elif answer_ok: + cer.verdict = "PARTIAL" # 라우팅은 틀렸으나 답변은 됨 + else: + cer.verdict = "FAIL" + + return cer + + # ── 결과 출력 ──────────────────────────────────────────────── def _truncate(text: str, n: int = 80) -> str: if not text: @@ -496,7 +567,6 @@ def _truncate(text: str, n: int = 80) -> str: def _print_summary_table(results: list[CaseEvalResult]) -> None: """markdown 표 stdout. weekly-log 박제용.""" - # ── t2c 표 ─────────────────────────────────── t2c_results = [r for r in results if r.suite == "t2c"] if t2c_results: print() @@ -514,7 +584,6 @@ def _print_summary_table(results: list[CaseEvalResult]) -> None: f"{r.elapsed_seconds:.1f}s | {_truncate(r.answer, 60)} |" ) - # ── local 표 ───────────────────────────────── local_results = [r for r in results if r.suite == "local"] if local_results: print() @@ -530,7 +599,23 @@ def _print_summary_table(results: list[CaseEvalResult]) -> None: f"{r.elapsed_seconds:.1f}s | {_truncate(r.answer, 60)} |" ) - # ── 전체 합계 ──────────────────────────────── + router_results = [r for r in results if r.suite == "router"] + if router_results: + print() + print("## W4 Routing Agent 평가 결과 (#21)") + print() + print("| ID | category | verdict | expected | actual | match | llm? | matched_keywords | elapsed |") + print("|----|----------|---------|:--------:|:------:|:-----:|:----:|------------------|--------:|") + for r in router_results: + route_match = "✅" if r.expected_route == r.actual_route else "❌" + llm_mark = "✅" if r.llm_used else "—" + kws = ",".join(r.matched_keywords[:3]) or "—" + print( + f"| {r.case_id} | {r.category} | **{r.verdict}** | " + f"{r.expected_route or '—'} | {r.actual_route or '—'} | " + f"{route_match} | {llm_mark} | {kws} | {r.elapsed_seconds:.1f}s |" + ) + print() pass_n = sum(1 for r in results if r.verdict == "PASS") partial_n = sum(1 for r in results if r.verdict == "PARTIAL") @@ -540,12 +625,21 @@ def _print_summary_table(results: list[CaseEvalResult]) -> None: ) print(f"전체 소요: {sum(r.elapsed_seconds for r in results):.1f}s") - # avg local elapsed (DoD: < 5s) if local_results: avg_local = sum(r.elapsed_seconds for r in local_results) / len(local_results) dod_mark = "✅" if avg_local < 5.0 else "⚠️" print(f"Local Retriever 평균 응답: {avg_local:.1f}s {dod_mark} (DoD: < 5초)") + if router_results: + avg_router = sum(r.elapsed_seconds for r in router_results) / len(router_results) + correct_n = sum( + 1 for r in router_results if r.expected_route == r.actual_route + ) + print( + f"Routing Agent 평균 응답: {avg_router:.1f}s · " + f"라우팅 정확도: {correct_n}/{len(router_results)}" + ) + def _print_t2c_case_detail(r: CaseEvalResult) -> None: print() @@ -594,19 +688,40 @@ def _print_local_case_detail(r: CaseEvalResult) -> None: print(f"**Note**: {r.note}") +def _print_router_case_detail(r: CaseEvalResult) -> None: + print() + print(f"### {r.case_id} [{r.category}] {r.verdict} (Routing Agent)") + print(f"**Q**: {r.question}") + print() + print(f"**Expected route**: {r.expected_route}") + print(f"**Actual route**: {r.actual_route}") + print(f"**Matched keywords**: {r.matched_keywords}") + print(f"**LLM used**: {r.llm_used}") + if r.llm_reasoning: + print(f"**LLM reasoning**: {r.llm_reasoning}") + print() + print(f"**Answer**: {_truncate(r.answer, 200)}") + if r.error: + print(f"**Error**: {r.error}") + print(f"**Answer hint hits**: {r.answer_hint_hits}") + if r.note: + print(f"**Note**: {r.note}") + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( "--case", type=str, default=None, - help="특정 케이스만 실행 (예: --case Q1, --case L3). 기본: 전체.", + help="특정 케이스만 실행 (예: --case Q1, --case L3, --case R2). 기본: 전체.", ) parser.add_argument( "--only-eval", action="store_true", - help="Q*/L* 만 (fallback F* 제외). 빠른 정성 확인용.", + help="Q*/L*/R* 만 (fallback F* 제외). 빠른 정성 확인용.", ) parser.add_argument( - "--suite", type=str, default=None, choices=["t2c", "local"], - help="suite 필터: t2c (Q*+F*) 또는 local (L*). 기본: 전체.", + "--suite", type=str, default=None, + choices=["t2c", "local", "router"], + help="suite 필터: t2c (Q*+F*), local (L*), router (R*). 기본: 전체.", ) parser.add_argument( "--json", type=Path, default=None, @@ -618,7 +733,6 @@ def main() -> int: ) args = parser.parse_args() - # ── 케이스 선택 ───────────────────────────────────── cases = list(EVAL_CASES) if args.case: cases = [c for c in cases if c.id == args.case] @@ -642,7 +756,6 @@ def main() -> int: logger.info(" - [%s] %s [%s] %s", c.suite, c.id, c.category, _truncate(c.question, 50)) - # ── 실행 ──────────────────────────────────────────── results: list[CaseEvalResult] = [] started_total = time.perf_counter() @@ -659,6 +772,9 @@ def main() -> int: elif case.suite == "local": lr = local_retrieve(case.question) cer = _evaluate_local_case(case, lr) + elif case.suite == "router": + rr = route_and_answer(case.question) + cer = _evaluate_router_case(case, rr) else: raise ValueError(f"unknown suite: {case.suite}") except Exception as exc: @@ -679,16 +795,16 @@ def main() -> int: if not args.no_detail: if cer.suite == "t2c": _print_t2c_case_detail(cer) - else: + elif cer.suite == "local": _print_local_case_detail(cer) + elif cer.suite == "router": + _print_router_case_detail(cer) elapsed_total = time.perf_counter() - started_total logger.info("\n=== 평가 완료 — %d 케이스, %.1fs ===", len(results), elapsed_total) - # ── 결과 출력 ─────────────────────────────────────── _print_summary_table(results) - # ── JSON 박제 ─────────────────────────────────────── if args.json: args.json.parent.mkdir(parents=True, exist_ok=True) with args.json.open("w", encoding="utf-8") as f: From 9a1c2a38f51462d08cf99ed2f71660724e1ad574 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 09:59:44 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs(weekly-log):=205/17=20W4=20community?= =?UTF-8?q?=20stub=20+=20routing=20agent=20=EB=B0=95=EC=A0=9C=20(#20=20+?= =?UTF-8?q?=20#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weekly-log/2026-05-17-community-router.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/weekly-log/2026-05-17-community-router.md diff --git a/docs/weekly-log/2026-05-17-community-router.md b/docs/weekly-log/2026-05-17-community-router.md new file mode 100644 index 0000000..68e655f --- /dev/null +++ b/docs/weekly-log/2026-05-17-community-router.md @@ -0,0 +1,121 @@ +# W4 — Community Summary stub + Routing Agent (#20 + #21) + +**2026-05-17 (Sun) — 코드 트랙 두 번째 세션** + +W4 마무리. Layer C (Community Summary) stub + Layer A/B/C 통합 라우팅 에이전트 박제. +PR #49 (#19 Local Retriever) 머지 직후 진행 — 시나리오 A 의 5/18 일정을 하루 당겨 5/17 안에 W4 전체 완료. + +## 작업 컨텍스트 + +- **선행 머지**: PR #49 (L4 PASS + Discovery 3 + L5 글로벌 fallback) 5/17 00:26 UTC 머지 (`438c1422`) +- **남은 W4 이슈**: #20 (Community Summary stub), #21 (Routing Agent) +- **목표**: 발표 슬라이드 13 (Local 데모) ✅ + 14 (Routing 명분) 의 R3 community stub 시연 강화 +- **데이터 환경**: Aura `9b57188f` (8 문서 / 610 노드 / 2,451 관계 / 1,189 MENTIONS, 5/16 적재) + +## 레퍼런스 조사 + +| 출처 | 통찰 | 적용 | +|------|------|------| +| Microsoft GraphRAG (2024-04) | Community Summary 는 **indexing time 에 사전 생성**. Global Search 는 map-reduce 로 community report 위에서 동작 | community detection / report 생성이 우리 환경에 없음 → 정직한 stub 만 (#20) | +| Microsoft GraphRAG 4 mode | Global / Local / DRIFT / Basic 으로 분류. DRIFT 는 Local + Global 합성 | 본 작전 외. 향후 W5+ DRIFT 검토 가능 | +| Sotaaz blog (2026-01) | `hybrid_search()` 의 if/elif/else 결정적 분기 — `is_global_question(q)` → global, `contains_entity(q)` → local | 우리 `_classify_by_keywords` 의 직접 모델 (#21) | +| LangChain `MultiRetrievalQAChain` | LLM router 표준 패턴. 그러나 *"same query may route to different sources"* (TDS 2025) 비일관성 | LLM 라우팅을 1단계로 안 두는 결정의 근거 | +| LangChain `EmbeddingRouterChain` | Semantic router — 각 route 의 예시 prompt 를 embedding 해두고 query 최근접 라우트 선택 | W5+ 확장 후보 (이번 PR 스코프 외, 사유: embedding API 추가) | +| Neo4j `ToolsRetriever` | `convert_to_tool(name, description)` 후 LLM 자동 선택 — agent-style routing | LLM fallback 의 system prompt 모델 (각 retriever 의 description 명시) (#21) | +| NeoConverse blog (2025-08) | *"Intelligent fallback mechanism — Defaults gracefully to Text2Cypher when no suitable agentic tool is identified"* | 우리 `DEFAULT_ROUTE = "t2c"` 의 산업 선례 (#21) | +| Memgraph Atomic GraphRAG (2026-03) | Analytical / Local / Global 의 3-way 분류 — 우리와 동일 | Routing 셋 R1~R3 의 매핑 그대로 (t2c=Analytical, local=Local, community=Global) | + +## 설계 결정 (5/17 박제) + +### #20 Community Summary — 순수 stub 으로 결정 + +- **거짓말 안 하기**: Microsoft GraphRAG 의 Global Search 는 community detection + community report 가 indexing time 에 생성되어 있어야 동작. 우리는 둘 다 없음. LLM 만으로 글로벌 답변 흉내내면 "그래프 기반" 이라는 표현이 거짓이 됨. +- **stub 응답 구성**: 글로벌 질의 분류 확인 + Layer C 미구현 명시 + 5/24+ ETA + 대안 안내 (Layer A/B 활용 권장). +- **LLM/Neo4j 호출 0회** → 즉시 응답 + Opik `@track` trace 만 남김. +- **인터페이스 박제**: `CommunitySummaryResult(answer, is_stub=True)` — 미래 진짜 Layer C 와 시그니처 호환 유지 의도. + +### #21 Routing Agent — 키워드 분기 + LLM fallback + +- **결정적 키워드 우선** — LangChain LLM router 의 일관성 문제 (TDS 2025) 회피. 디버깅 / 평가 / 발표 시연 모두 명확. +- **LLM fallback 은 필요한 만큼만** — 키워드 매칭 0개 시에만 호출. 단순 factual 질문 (Q1~Q6) 은 LLM 호출 없이 t2c 로 즉시 라우팅 → 비용 0, 지연 0. +- **graceful default = t2c** — LLM 실패 / unknown route → t2c 로 fallback (NeoConverse 패턴). + +**키워드 매핑**: +- `LOCAL_KEYWORDS`: 관계, 관련, 연관, 영향, 함께, 이웃, 어떻게, co-mention, 관계는, 관련된, 연관된 +- `COMMUNITY_KEYWORDS`: 트렌드, 전체, 흐름, 주요 트렌드, 주제, 패턴, 주요 흐름, 전반, 글로벌, community, communities, topic, topics, themes, 전체적, 통합, 사전체 +- 그 외 → t2c + +**Tie-break**: 두 셋 동시 매칭 시 더 많은 쪽. 동률이면 community 우선 (전체 질의는 보통 community 의도). + +## 변경 사항 + +**신규** +- `retrieval/community_summary.py` — Layer C stub 진입점 (`@track`, 138 lines) +- `retrieval/prompts/community_summary_v1.md` — W5+ 실제 구현 시 채울 placeholder +- `retrieval/router.py` — Routing Agent (`decide_route`, `route_and_answer`, 424 lines) +- `retrieval/prompts/router_v1.md` — LLM fallback system prompt + 5 few-shot + +**수정** +- `retrieval/__init__.py` — 신규 export 8개 추가 +- `scripts/run_w4_eval.py` — `expected_route` 필드 + R1~R3 케이스 + `--suite router` + routing 결과 표 + +## 평가 셋 R1~R3 — 기존 Q*/L* 의도 재활용 + +| ID | 출처 | 질문 | 기대 라우트 | 검증 포인트 | +|----|------|------|-------------|-------------| +| **R1** | Q2 동일 | "미래에셋증권 4분기 보고서의 Table 은 몇 개?" | `t2c` | 키워드 매칭 0개 → **LLM fallback 트리거 검증** + t2c 답변 "6" | +| **R2** | L1 변형 | "두산밥캣과 함께 언급된 리스크는 어떻게 관련되어 있나?" | `local` | "관련/어떻게/함께/리스크" **4중 매칭** → local 즉시 분기 | +| **R3** | L5 동일 | "전체 8문서의 주요 트렌드와 흐름은?" | `community` | "전체/트렌드/흐름" 매칭 → community **stub 응답** 검증 | + +R2 는 5/17 정성 검증 시 두산밥캣 entity 매칭 0개로 graceful fallback 가는 케이스 (PR #49 박제 내용 그대로) — 라우팅은 PASS, retriever 내부 fallback 동작 검증 겸함. + +## 실행 방법 + +```bash +git pull +# Routing 전용 평가 +uv run python -m scripts.run_w4_eval --suite router --json eval/w4_router_2026-05-17.json + +# 전체 (Q* + F* + L* + R*) +uv run python -m scripts.run_w4_eval + +# 개별 case +uv run python -m scripts.run_w4_eval --case R2 +``` + +## DoD + +**#20 Community Summary stub** +- [x] `community_summary(question)` 진입점 — Layer C 미구현 안내 응답 +- [x] `CommunitySummaryResult` 시그니처 박제 (is_stub=True) +- [x] `@track` Opik trace +- [x] LLM/Neo4j 호출 0회 (정직한 stub) +- [x] R3 케이스로 평가 통합 + +**#21 Routing Agent** +- [x] 키워드 분기 1단계 (`_classify_by_keywords`) +- [x] LLM fallback 2단계 (`_classify_by_llm`) — JSON 응답 강제, tenacity 2 attempts +- [x] graceful default = t2c +- [x] 통합 진입점 `route_and_answer` (`@track`) +- [x] R1~R3 평가 셋 + `--suite router` 옵션 +- [x] `decide_route` / `route_and_answer` export + +## 정성 검증 결과 + +*5/17 사용자 로컬 실행 후 채울 예정 — 본 PR 머지 전 박제 완료.* + +기대: +- **R1**: 키워드 0개 → LLM fallback → t2c 라우팅 → 답변 "6" +- **R2**: 키워드 4중 매칭 → local 즉시 분기 → 두산밥캣 entity 매칭 0개 graceful fallback 메시지 +- **R3**: 키워드 매칭 → community 즉시 분기 → stub 응답 (Layer C 미구현 안내) + +## 발표 슬라이드 연결 + +- **슬라이드 14 (Routing 명분)** ← R3 community stub 시연으로 강화. PR #49 까지는 L5 PASS 만 있었음 (local_retriever 내부의 "Layer C 적합 안내" fallback). 본 PR 머지 후엔 **명시적 routing decision trace** (matched_keywords, llm_used 필드) + Layer C stub 응답으로 시연 가능. +- R2 의 키워드 4중 매칭 trace 도 흥미로움 — Opik nested span 으로 "라우팅 → entity 매칭 0개 → graceful fallback" 의 3단계 시각화 가능. + +## 후속 + +- **Issue #50** (Entity 추출 한계 — Company 라벨에 회사명 자체 부재) — 5/24+ W5 영역, 발표 전 해결 불요. 본 PR 과 독립. +- **W5+ Layer C 실제 구현**: community detection (Leiden / Louvain) → community report 생성 → 진짜 Global Search 구현. 본 PR 의 stub 시그니처를 그대로 활용 가능. +- **Semantic Router 확장**: LangChain `EmbeddingRouterChain` 패턴으로 키워드 어휘 한계 보완. embedding API 도입 후 검토. From 6f6ec36a452e88bb56daad46e7de1b29f513be27 Mon Sep 17 00:00:00 2001 From: HyunSang Jang Date: Sun, 17 May 2026 10:04:35 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs(weekly-log):=20=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=203/3=20PASS=20?= =?UTF-8?q?=EB=B0=95=EC=A0=9C=20+=20R2/R3=20=EB=A7=A4=EC=B9=AD=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EC=A0=95=ED=99=95=EB=8F=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weekly-log/2026-05-17-community-router.md | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/weekly-log/2026-05-17-community-router.md b/docs/weekly-log/2026-05-17-community-router.md index 68e655f..9df4fbd 100644 --- a/docs/weekly-log/2026-05-17-community-router.md +++ b/docs/weekly-log/2026-05-17-community-router.md @@ -64,8 +64,8 @@ PR #49 (#19 Local Retriever) 머지 직후 진행 — 시나리오 A 의 5/18 | ID | 출처 | 질문 | 기대 라우트 | 검증 포인트 | |----|------|------|-------------|-------------| | **R1** | Q2 동일 | "미래에셋증권 4분기 보고서의 Table 은 몇 개?" | `t2c` | 키워드 매칭 0개 → **LLM fallback 트리거 검증** + t2c 답변 "6" | -| **R2** | L1 변형 | "두산밥캣과 함께 언급된 리스크는 어떻게 관련되어 있나?" | `local` | "관련/어떻게/함께/리스크" **4중 매칭** → local 즉시 분기 | -| **R3** | L5 동일 | "전체 8문서의 주요 트렌드와 흐름은?" | `community` | "전체/트렌드/흐름" 매칭 → community **stub 응답** 검증 | +| **R2** | L1 변형 | "두산밥캣과 함께 언급된 리스크는 어떻게 관련되어 있나?" | `local` | "관련/함께/어떻게" **3중 매칭** → local 즉시 분기 | +| **R3** | L5 동일 | "전체 8문서의 주요 트렌드와 흐름은?" | `community` | "트렌드/전체/흐름/주요 트렌드" **4중 매칭** → community **stub 응답** 검증 | R2 는 5/17 정성 검증 시 두산밥캣 entity 매칭 0개로 graceful fallback 가는 케이스 (PR #49 박제 내용 그대로) — 라우팅은 PASS, retriever 내부 fallback 동작 검증 겸함. @@ -100,19 +100,47 @@ uv run python -m scripts.run_w4_eval --case R2 - [x] R1~R3 평가 셋 + `--suite router` 옵션 - [x] `decide_route` / `route_and_answer` export -## 정성 검증 결과 +## 정성 검증 결과 (5/17 10:02 KST 로컬 실행) -*5/17 사용자 로컬 실행 후 채울 예정 — 본 PR 머지 전 박제 완료.* +**3/3 PASS · 라우팅 정확도 100% · 전체 소요 12.8s · Routing 평균 4.3s** -기대: -- **R1**: 키워드 0개 → LLM fallback → t2c 라우팅 → 답변 "6" -- **R2**: 키워드 4중 매칭 → local 즉시 분기 → 두산밥캣 entity 매칭 0개 graceful fallback 메시지 -- **R3**: 키워드 매칭 → community 즉시 분기 → stub 응답 (Layer C 미구현 안내) +| ID | category | verdict | expected | actual | match | llm? | matched_keywords | elapsed | +|----|----------|---------|:--------:|:------:|:-----:|:----:|------------------|--------:| +| R1 | routing-factual | **PASS** | t2c | t2c | ✅ | ✅ | — | 7.2s | +| R2 | routing-relation | **PASS** | local | local | ✅ | — | 관련, 함께, 어떻게 | 5.6s | +| R3 | routing-global | **PASS** | community | community | ✅ | — | 트렌드, 전체, 흐름, 주요 트렌드 | 0.0s | + +### R1 — LLM fallback 트리거 검증 ✅ + +- 키워드 매칭 0개 → LLM fallback 진입 (의도) +- Kimi reasoning: *"특정 문서 내의 Table 개수를 묻는 명시적 사실 질문. Text2Cypher가 aggregation 쿼리를 통해 처리할 수 있다."* → t2c 라우팅 +- t2c 결과: `MATCH (d:Document {filename: '미래에셋증권_4분기_실적보고서.pdf'})-[:HAS_SECTION]->(:Section)-[:CONTAINS_TABLE]->(t:Table) RETURN count(t) AS table_count` → 6 +- 답변: *"미래에셋증권 4분기 실적보고서에는 총 6개의 표(Table) 가 적재되어 있습니다."* (Q2 답과 일치) + +### R2 — 키워드 3중 매칭 + graceful fallback ✅ + +- 키워드 3개 매칭 (관련 / 함께 / 어떻게) → local 즉시 분기 (LLM 호출 없음, 결정적) +- 두산밥캣 entity 매칭 0개 → graceful fallback 응답 (L1 PASS 의 동일 fallback 메시지 재현) +- 라우팅 정확도 PASS + retriever 내부 fallback 동작 검증 겸함 + +### R3 — Community stub 즉시 응답 ✅ + +- 키워드 4개 매칭 (트렌드 / 전체 / 흐름 / 주요 트렌드) → community 즉시 분기 +- LLM/Neo4j 호출 0회 → **elapsed 0.0s** (의도대로) +- 답변: 글로벌 질의 분류 확인 + Layer C 미구현 명시 + Layer A/B 대안 안내 +- Answer hint hits 5/5 (stub 메시지의 핵심 키워드 모두 포함) + +### Opik trace + +- 프로젝트: `hyunsang-doc-graph-agent` +- 모든 R* 케이스의 라우팅 decision + 하위 retriever 호출이 nested span 으로 박제됨 +- R1 의 LLM fallback 호출 + t2c 의 schema 추출 / Cypher 생성 / 실행 3-step 도 분리 확인 ## 발표 슬라이드 연결 - **슬라이드 14 (Routing 명분)** ← R3 community stub 시연으로 강화. PR #49 까지는 L5 PASS 만 있었음 (local_retriever 내부의 "Layer C 적합 안내" fallback). 본 PR 머지 후엔 **명시적 routing decision trace** (matched_keywords, llm_used 필드) + Layer C stub 응답으로 시연 가능. -- R2 의 키워드 4중 매칭 trace 도 흥미로움 — Opik nested span 으로 "라우팅 → entity 매칭 0개 → graceful fallback" 의 3단계 시각화 가능. +- R1 의 LLM fallback reasoning ("aggregation 쿼리를 통해 처리할 수 있다") 도 발표 데모로 활용 가능 — "키워드 없는 모호한 질문도 LLM 이 reasoning 후 라우팅". +- R2 의 키워드 3중 매칭 trace 도 흥미로움 — Opik nested span 으로 "라우팅 → entity 매칭 0개 → graceful fallback" 의 3단계 시각화 가능. ## 후속