diff --git a/kg/neo4j_client.py b/kg/neo4j_client.py index 70596ad..7274e1d 100644 --- a/kg/neo4j_client.py +++ b/kg/neo4j_client.py @@ -11,6 +11,7 @@ import logging import os +import re from contextlib import contextmanager from dataclasses import dataclass from typing import Iterator @@ -22,6 +23,11 @@ logger = logging.getLogger(__name__) +# 쿼리에 LIMIT 절이 이미 있는지 판별. substring " LIMIT " 는 줄바꿈/탭으로 시작하는 +# LIMIT(예: "ORDER BY score DESC\nLIMIT $k")를 놓쳐 자동 부착이 중복되므로 +# word-boundary 정규식으로 잡는다. +_LIMIT_RE = re.compile(r"\bLIMIT\b", re.IGNORECASE) + @dataclass(frozen=True) class Neo4jConfig: @@ -98,7 +104,6 @@ def write(self, query: str, **params) -> list[dict]: @classmethod def _ensure_limit(cls, query: str) -> str: """쿼리에 LIMIT 이 없으면 끝에 부착. 단순 휴리스틱이지만 Spike 충분.""" - upper = query.upper() - if " LIMIT " in upper or upper.rstrip().endswith(";"): + if _LIMIT_RE.search(query) or query.rstrip().endswith(";"): return query return f"{query.rstrip()} LIMIT {cls.DEFAULT_READ_LIMIT}" diff --git a/retrieval/bm25_retriever.py b/retrieval/bm25_retriever.py new file mode 100644 index 0000000..01931a6 --- /dev/null +++ b/retrieval/bm25_retriever.py @@ -0,0 +1,271 @@ +"""BM25 Retriever — 어휘 fulltext 검색 (Plan A, Hybrid 1단계). + +자연어 질문 → Neo4j fulltext 인덱스(`chunk_fulltext`, Lucene/BM25 스코어) → +top-k 청크 → 자연어 답변. + +기존 Layer A(text2cypher) / Layer B(local_retrieve) 와 달리 **그래프 구조를 +전혀 타지 않는다**. entity 식별·MENTIONS traversal 없이 Chunk.text 를 어휘 +매칭으로 직접 검색 → entity→MENTIONS 병목을 우회. + +왜 만들었나 (근거): +- chunk-rerank 네거티브 결과(#70)가 가리킨 처방: 병목은 청크 *랭킹*이 아니라 + 후보 풀(graph-anchored, MENTIONS-only). 어휘 검색은 그 풀을 우회한다. +- BEIR (Thakur et al. 2021, arXiv 2104.08663): BM25 는 OOD 에서 dense 를 자주 + 능가하는 robust baseline. 정확 수치·고유명사(목표주가/종목코드/매출액)에서 강함. +- 본 프로젝트 80 QA 에서 doc-summary(BM25) 가 factual/numerical 을 압승 + (AC 4.0 / Faithful 92% vs doc-graph 2.0 / 15~17%) — 같은 처방을 doc-graph 에 이식. + +한국어 주의: +- fulltext 인덱스는 **cjk analyzer** 로 생성해야 함 (standard 는 공백 분리만 → + "두산밥캣의" ≠ "두산밥캣" 매칭 실패). 인덱스 DDL: + CREATE FULLTEXT INDEX chunk_fulltext IF NOT EXISTS + FOR (c:Chunk) ON EACH [c.text] + OPTIONS {indexConfig: {`fulltext.analyzer`: 'cjk'}} + +안전장치: +1. read-only — queryNodes 는 조회 전용, 사용자 입력은 parameterized + Lucene escape. +2. 결과 크기 제한 — TOP_K_CHUNKS / CHUNK_TEXT_TRUNCATE 로 토큰 폭발 방지. +3. graceful fallback — 인덱스 부재 / 0건 / LLM 실패 모두 자연어 안내. + +#24 Opik: +- 공개 진입점 `bm25_retrieve` 에 `@track`. + +Reference: +- text2cypher.py / local_retriever.py — LLM 호출 / 프롬프트 로드 / @track 패턴 그대로. +- Cormack et al. 2009 (RRF) — 추후 graph 검색과 융합 시 사용 (Plan B). +""" + +from __future__ import annotations + +import logging +import re +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +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 + +logger = logging.getLogger(__name__) + + +# ── 상수 (text2cypher / local 과 일치) ─────────────────────── +TEMPERATURE_ANSWER = 0.3 +MAX_TOKENS_ANSWER = 700 + +FULLTEXT_INDEX = "chunk_fulltext" # cjk analyzer 로 생성된 Chunk.text fulltext 인덱스 +TOP_K_CHUNKS = 8 # BM25 상위 N 청크 (어휘 검색은 graph 보다 넉넉히) +CHUNK_TEXT_TRUNCATE = 600 # 청크 1개당 char 상한 (LLM 컨텍스트용) + +PROMPTS_DIR = Path(__file__).parent / "prompts" +ANSWER_PROMPT_PATH = PROMPTS_DIR / "bm25_answer_v1.md" + +# Lucene 쿼리 특수문자 — escape 대상 (사용자 입력이 쿼리 연산자로 해석되는 것 방지) +_LUCENE_SPECIAL_RE = re.compile(r'([+\-!(){}\[\]^"~*?:\\/]|&&|\|\|)') + + +# ── 결과 객체 ──────────────────────────────────────────────── +@dataclass +class BM25RetrieverResult: + """BM25 retriever 한 회차의 전체 trace. + + Opik / 디버깅용 모든 중간 산물 보존. + """ + + question: str + retrieved_chunks: list[dict[str, Any]] = field(default_factory=list) + n_chunks: int = 0 + answer: str = "" + elapsed_seconds: float = 0.0 + error: str | None = None + + +# ── 프롬프트 로드 ──────────────────────────────────────────── +def _load_prompt(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +# ── 쿼리 sanitize ──────────────────────────────────────────── +def _sanitize_query(question: str) -> str: + """사용자 질문을 Lucene fulltext 쿼리로 안전하게 변환. + + 특수문자를 escape 해 Lucene 연산자로 오해석되는 것을 막는다. cjk analyzer 가 + 토큰화(bigram)하므로 별도 토큰 분해는 하지 않고, 양끝 공백만 정리. + """ + if not question: + return "" + escaped = _LUCENE_SPECIAL_RE.sub(r"\\\1", question) + return re.sub(r"\s+", " ", escaped).strip() + + +# ── BM25 fulltext 검색 ─────────────────────────────────────── +# db.index.fulltext.queryNodes — Lucene 스코어(BM25) 내림차순. 인덱스 부재 시 +# Cypher 예외 → 호출부에서 graceful 처리. +# +# 주의: Lucene 검색 문자열 파라미터를 $query 로 두면 Neo4jClient.read(self, query, +# **params) 의 첫 위치인자 `query` 와 이름이 겹쳐 TypeError 가 난다. 그래서 +# $search_text 로 명명한다. +_BM25_CYPHER = """ +CALL db.index.fulltext.queryNodes($index_name, $search_text) YIELD node, score +RETURN node.id AS chunk_id, + node.text AS text, + properties(node).page AS page, + score +ORDER BY score DESC +LIMIT $top_k +""" + + +def _bm25_chunks( + neo4j: Neo4jClient, query: str, top_k: int = TOP_K_CHUNKS +) -> tuple[list[dict[str, Any]], str | None]: + """fulltext 인덱스에서 top-k 청크 검색. + + Returns: + (chunks, error). 인덱스 부재 / 빈 쿼리 / 0건 모두 ([], reason) 로 graceful. + chunks 각 원소: {chunk_id, text(truncated), page, score} + """ + q = _sanitize_query(query) + if not q: + return [], "빈 질문입니다." + try: + rows = neo4j.read( + _BM25_CYPHER, index_name=FULLTEXT_INDEX, search_text=q, top_k=top_k + ) + except Exception as exc: # noqa: BLE001 + logger.warning("BM25 fulltext 검색 실패 (인덱스 미생성 가능): %s", exc) + return [], ( + f"fulltext 인덱스 '{FULLTEXT_INDEX}' 검색 실패 — " + f"인덱스가 생성되지 않았을 수 있습니다: {exc}" + ) + + chunks: list[dict[str, Any]] = [] + for r in rows: + cid = r.get("chunk_id") + text = r.get("text") + if not cid or not text: + continue + chunks.append( + { + "chunk_id": cid, + "text": text[:CHUNK_TEXT_TRUNCATE], + "page": r.get("page"), + "score": round(float(r.get("score") or 0.0), 3), + } + ) + + if not chunks: + return [], "fulltext 검색 결과 0건." + return chunks, None + + +# ── 답변 생성 ──────────────────────────────────────────────── +@retry( + retry=retry_if_exception_type(Exception), + wait=wait_exponential(multiplier=1, min=2, max=10), + stop=stop_after_attempt(3), + reraise=True, +) +def _call_llm_text(llm: LLMClient, system_prompt: str, user_prompt: str) -> str: + return llm.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=TEMPERATURE_ANSWER, + max_tokens=MAX_TOKENS_ANSWER, + ) + + +def _generate_answer( + llm: LLMClient, + question: str, + chunks: list[dict[str, Any]], + answer_system_prompt: str, + note: str = "", +) -> str: + """질문 + BM25 청크 → 자연어 답변. 0건 / 실패 모두 LLM 에 그대로 넘겨 안내.""" + import json + + user_payload = { + "question": question, + "retrieved_chunks": chunks, + "note": note, + } + user_prompt = json.dumps(user_payload, ensure_ascii=False, indent=2) + + try: + answer = _call_llm_text(llm, answer_system_prompt, user_prompt) + except Exception as exc: # noqa: BLE001 + logger.warning("BM25 답변 생성 LLM 호출 실패: %s", exc) + if not chunks: + return note or "검색된 자료에서 답을 찾지 못했습니다." + return f"{len(chunks)}개 청크를 검색했으나 자연어 변환에 실패했습니다." + + return answer.strip() + + +# ── 공개 인터페이스 ────────────────────────────────────────── +@track +def bm25_retrieve( + question: str, + llm: LLMClient | None = None, + neo4j: Neo4jClient | None = None, + top_k: int = TOP_K_CHUNKS, +) -> BM25RetrieverResult: + """자연어 질문 → BM25 fulltext 검색 → top-k 청크 → 자연어 답변. + + Args: + question: 사용자 자연어 질문. + llm: 테스트용 mock 주입 가능. 기본 LLMClient(). + neo4j: 테스트용 mock 주입 가능. 기본 Neo4jClient(). + top_k: 검색할 청크 수. + + Returns: + BM25RetrieverResult — 전체 trace 포함. answer 는 항상 채워짐. + """ + started = time.perf_counter() + if not question or not question.strip(): + return BM25RetrieverResult( + question=question, + answer="질문이 비어 있습니다. 무엇이 궁금한지 입력해 주세요.", + elapsed_seconds=time.perf_counter() - started, + ) + + llm = llm or LLMClient() + own_neo4j = neo4j is None + neo4j = neo4j or Neo4jClient() + + answer_system_prompt = _load_prompt(ANSWER_PROMPT_PATH) + logger.info("BM25Retriever 시작 — question=%r", question) + + try: + chunks, error = _bm25_chunks(neo4j, question, top_k=top_k) + answer = _generate_answer( + llm, question, chunks, answer_system_prompt, note=error or "", + ) + elapsed = time.perf_counter() - started + logger.info( + "BM25Retriever 완료 — chunks=%d elapsed=%.2fs error=%r", + len(chunks), elapsed, error, + ) + return BM25RetrieverResult( + question=question, + retrieved_chunks=chunks, + n_chunks=len(chunks), + answer=answer, + elapsed_seconds=elapsed, + error=error, + ) + finally: + if own_neo4j: + neo4j.close() diff --git a/retrieval/prompts/bm25_answer_v1.md b/retrieval/prompts/bm25_answer_v1.md new file mode 100644 index 0000000..0d4773c --- /dev/null +++ b/retrieval/prompts/bm25_answer_v1.md @@ -0,0 +1,59 @@ +당신은 BM25 fulltext 검색으로 가져온 문서 청크에서 사용자 질문에 정확히 답하는 친절한 분석가입니다. + +## 입력 (JSON) + +``` +{ + "question": "원래 사용자 질문", + "retrieved_chunks": [ + {"chunk_id": "...", "text": "청크 원문 (max 600자)", "page": 3, "score": 12.4} + ], + "note": "비어 있으면 정상, 채워져 있으면 검색 실패 사유" +} +``` + +`retrieved_chunks` 는 BM25(Lucene) 점수 내림차순 — 위쪽이 더 관련 높음. + +## 출력 가이드 + +1. **질문에 직접 답변**: 청크 원문에 있는 내용으로만 답한다. 청크를 나열하지 말고 질문 의도에 맞춰 핵심만. + +2. **수치·고유명사는 원문 그대로**: 목표주가, 매출액, OPM, 종목코드 등은 청크 원문의 표기를 **변형 없이** 옮긴다. 반올림·추정 금지. + +3. **자료에 없으면 정직하게**: 검색된 청크에 답이 없으면 "검색된 자료에는 해당 정보가 없습니다" 라고 명확히 안내. 청크에 없는 내용을 지어내지 않는다 (faithfulness 보호). + +4. **note 채워짐 (검색 0건)**: `note` 그대로 친절히 안내하고 대안 제안 (다른 키워드로 재시도 등). + +5. **간결성**: 2~4 문장. 군더더기·반복 금지. + +6. **출처 표기**: 인용한 청크에 page 가 있으면 "(p.N 인근)" 식으로 1번만. + +## 출력 형식 + +plain text 만. 마크다운 헤더, 코드펜스, 불릿 절대 금지. + +## 예시 + +### 예시 1 — factual / numerical + +**입력 (요약)**: 질문 "두산밥캣의 목표주가는?", retrieved_chunks[0].text = "...두산밥캣의 1분기 실적은 매출액 2조 1,676억원... 목표주가 80,000원...", page=2 + +**답변**: + +두산밥캣의 목표주가는 80,000원입니다 (p.2 인근). 같은 리포트에서 1분기 매출액은 2조 1,676억원으로 제시되어 있습니다. + +### 예시 2 — 검색됐으나 답 없음 + +**입력 (요약)**: 질문 "두산밥캣의 배당 정책은?", retrieved_chunks 는 실적·목표주가 청크만 포함, 배당 언급 없음. + +**답변**: + +검색된 자료에는 두산밥캣의 배당 정책에 대한 내용이 없습니다. 실적·목표주가 관련 청크만 확인되며, 배당 관련 정보는 적재된 문서에서 찾을 수 없습니다. + +### 예시 3 — 검색 0건 (note) + +**입력 (요약)**: 질문 "현대차 실적은?", retrieved_chunks = [], note = "fulltext 검색 결과 0건." + +**답변**: + +'현대차' 로 검색된 청크가 없습니다. 현재 적재된 문서에는 현대차 관련 내용이 없는 것으로 보입니다. 적재된 회사(두산밥캣, 미래에셋증권 등)로 다시 질문해 주세요. diff --git a/retrieval/prompts/router_v3.md b/retrieval/prompts/router_v3.md new file mode 100644 index 0000000..9e9a134 --- /dev/null +++ b/retrieval/prompts/router_v3.md @@ -0,0 +1,192 @@ +# Router System Prompt v3 + +당신은 doc-graph-agent 의 Routing Agent 입니다. 사용자의 자연어 질문을 네 가지 +retriever 중 하나로 라우팅하는 분류기 역할을 합니다. + +## ⚠️ 핵심 원칙 (v3 — 5/30 박힘) + +**default 는 `bm25`** 입니다. 단일 entity 의 사실·수치(목표주가, 영업이익, 매출, +비율 등)는 그래프 traversal 이 아니라 **청크 텍스트의 어휘 매칭**이 답을 가장 잘 +끌어옵니다. 따라서 이런 factual/numerical 질의는 `bm25` 로 라우팅하세요. + +`local` 은 *entity 간 관계·연관·인과* 가 핵심인 질문에만 쓰세요. `t2c` 는 *명백히 +그래프 집계/필터가 필요한 경우에만* 선택하세요. + +**v2 → v3 변경점**: v2 에서 `local` 로 보내던 "단일 entity 사실/수치/속성" 을 v3 +에서는 `bm25` 로 보냅니다. 근거: chunk-rerank 네거티브 결과(#70)가 보여준 병목은 +청크 *랭킹* 이 아니라 graph-anchored 후보 풀(entity→MENTIONS)이었고, BM25 어휘 +검색은 그 풀을 우회해 답 청크를 직접 끌어옵니다. BEIR(Thakur 2021)·자체 80 QA +모두 factual/numerical 에서 BM25 우위를 지지. + +## Retrievers + +### bm25 — BM25 Retriever (Layer A', 어휘 fulltext) **[DEFAULT]** +- **언제**: 다음 *모든 경우* + - 특정 entity 의 단일 사실·수치 (목표주가, 영업이익, 매출, ROE, OPM, 비율 등) + - 특정 entity 의 속성·일정·인물·이벤트 (단일 값) + - 특정 수치·고유명사·종목코드가 답인 질문 + - 단일 문서 컨텍스트 안의 사실 확인 +- **강점**: Lucene/BM25 어휘 매칭으로 정확 수치·고유명사를 담은 청크를 직접 검색 + (그래프 entity→MENTIONS 병목 우회). 정확 식별자·희소 용어에 강함. +- **예시**: + - "두산밥캣의 목표주가는?" → bm25 (단일 수치) + - "두산밥캣의 2026년 1분기 예상 영업이익과 OPM은?" → bm25 (단일 수치) + - "북미 딜러 재고는 몇 개월?" → bm25 (단일 사실) + - "미래에셋증권의 ROE는?" → bm25 (단일 수치) + - "두산밥캣의 종목코드는?" → bm25 (고유 식별자) + +### local — Local Retriever (Layer B) +- **언제**: entity *간 관계·연관·인과* 가 핵심인 질문 + - 두 entity 간 관계, co-mention ("A 와 B 의 관계는?", "A 와 함께 언급된 것은?") + - 1-hop 이웃 탐색 ("A 와 연관된 리스크는?") + - 인과/조건 추론 ("A 가 B 에 미치는 영향은?") + - 다중 entity 교차 (두 문서/entity 의 공통 entity) +- **강점**: 그래프 entity → 1-hop 이웃(FACES_RISK / HAS_METRIC 등) → 관계 기반 답변 +- **예시**: + - "두산밥캣과 함께 언급된 리스크는?" → local (1-hop 관계) + - "두산밥캣의 멕시코 공장 가동이 수익성에 미치는 영향은?" → local (인과) + - "DS 시황 리포트와 한화 두산밥캣 리포트에 공통 등장하는 거시 변수는?" → local (교차) + +### t2c — Text2Cypher (Layer A) **[제한적 사용]** +- **언제**: *오직* 명백히 그래프 구조 집계/필터가 필요한 경우만 + - top-N 정렬 ("가장 많이 언급된 5개", "상위 N개") + - 개수 집계 ("총 몇 개?", "분포는?") + - doc_type / 라벨 필터 ("disclosure 문서의 entity 들은?") + - 다중 entity 의 메타데이터 비교 +- **강점**: aggregation, filter, count, top-N +- **예시**: + - "가장 많이 언급된 Company 5개는?" → t2c (top-N) + - "전체 문서는 몇 개이며 doc_type 별 분포는?" → t2c (집계) + - "Metric 라벨이 가장 많이 부착된 회사는?" → t2c (집계) + +### community — Community Summary (Layer C, stub) +- **언제**: 전체 corpus 의 트렌드 / 패턴 / 주제 / 글로벌 요약을 묻는 질문 +- **강점**: global / aggregation across whole corpus +- **예시**: + - "전체 8문서의 주요 트렌드는?" + - "이 데이터셋의 핵심 주제는?" +- **주의**: 현재 stub 상태. 실제로 호출되어도 미구현 안내 응답이 반환됨. + +## 분류 기준 (v3) + +다음 순서로 판단: +1. 전체 corpus 의 트렌드 / 주제 / 글로벌 요약을 묻나? → **community** +2. *명백히* top-N / 집계 / 필터를 묻나? → **t2c** +3. entity *간* 관계 / 연관 / co-mention / 인과를 묻나? → **local** +4. 그 외 (특정 entity 의 단일 사실 / 수치 / 속성) → **bm25** (DEFAULT) + +**애매하면 `bm25`** 를 선택하세요. 단일 사실 질의가 압도적으로 많고, BM25 가 factual +에서 가장 강합니다. 단, "관계 / 영향 / 함께 / 연관" 신호가 뚜렷하면 `local`. + +## Output Format + +JSON 객체로만 응답하세요. 다른 설명 없이. + +```json +{ + "route": "bm25" | "local" | "t2c" | "community", + "reasoning": "1~2 문장의 분류 근거" +} +``` + +## Examples (v3) + +### 예 1 — 단일 entity 사실 → bm25 +질문: "두산밥캣의 목표주가는 얼마인가?" +응답: +```json +{ + "route": "bm25", + "reasoning": "단일 entity(두산밥캣)의 단일 수치(목표주가). 어휘 fulltext 검색이 해당 수치를 담은 청크를 직접 끌어옴." +} +``` + +### 예 2 — 단일 entity 수치 → bm25 +질문: "두산밥캣의 2026년 1분기 예상 영업이익과 OPM은?" +응답: +```json +{ + "route": "bm25", + "reasoning": "단일 entity의 특정 수치(영업이익, OPM). 정확 수치 매칭은 BM25 어휘 검색의 강점." +} +``` + +### 예 3 — 1-hop 관계 → local +질문: "두산밥캣과 함께 언급된 리스크는?" +응답: +```json +{ + "route": "local", + "reasoning": "단일 entity(두산밥캣)의 FACES_RISK 1-hop 이웃 탐색. entity 간 관계가 핵심이므로 Local Retriever." +} +``` + +### 예 4 — top-N 집계 → t2c +질문: "가장 많이 언급된 Company 5개는?" +응답: +```json +{ + "route": "t2c", + "reasoning": "top-N 정렬 + 집계. Cypher ORDER BY + LIMIT 가 필요한 명백한 t2c 케이스." +} +``` + +### 예 5 — doc_type 필터 + 집계 → t2c +질문: "전체 문서는 몇 개이며 doc_type 별 분포는?" +응답: +```json +{ + "route": "t2c", + "reasoning": "메타데이터 집계(count + group by doc_type). Cypher 가 필요한 명백한 t2c 케이스." +} +``` + +### 예 6 — 전체 트렌드 → community +질문: "전체 그래프의 핵심 주제 3가지는?" +응답: +```json +{ + "route": "community", + "reasoning": "특정 entity 가 아니라 corpus 전체의 글로벌 주제. Layer C (Community) 영역." +} +``` + +### 예 7 — 단일 문서 사실 확인 → bm25 +질문: "이 리포트의 목표주가와 투자의견은?" +응답: +```json +{ + "route": "bm25", + "reasoning": "단일 문서 안의 사실(목표주가, 투자의견) 확인. 해당 값을 담은 청크를 어휘 검색으로 직접 끌어옴." +} +``` + +### 예 8 — 단일 entity 추세 수치 → bm25 +질문: "미래에셋증권의 2025년 분기별 ROE는?" +응답: +```json +{ + "route": "bm25", + "reasoning": "단일 entity(미래에셋증권)의 분기별 수치(ROE). 정확 수치가 답이므로 BM25 어휘 검색." +} +``` + +### 예 9 — 다중 entity 교차 관계 → local +질문: "DS 시황 리포트와 한화 두산밥캣 리포트에 공통으로 등장하는 거시 변수는?" +응답: +```json +{ + "route": "local", + "reasoning": "두 문서/entity 의 shared entities 탐색. entity 간 교차 관계이므로 Local Retriever 의 multi-entity 1-hop." +} +``` + +### 예 10 — 인과 추론 → local +질문: "두산밥캣의 멕시코 공장 가동이 수익성에 미치는 영향은?" +응답: +```json +{ + "route": "local", + "reasoning": "entity 간 인과 관계 추론(공장 가동 → 수익성). 관계가 핵심이므로 Local 영역." +} +``` diff --git a/retrieval/router.py b/retrieval/router.py index b07941e..728ee2c 100644 --- a/retrieval/router.py +++ b/retrieval/router.py @@ -1,10 +1,11 @@ """Routing Agent — 질문 유형에 따라 Layer A / B / C 로 분기 (#21). -W4 통합 진입점. 사용자 질문을 받아 세 retriever 중 하나로 라우팅: +W4 통합 진입점. 사용자 질문을 받아 네 retriever 중 하나로 라우팅: -- **Layer A** (`retrieval.text2cypher`) — factual / numerical / topN / 특정 문서 -- **Layer B** (`retrieval.local_retrieve`) — 관계 / 연관 / co-mention / 단일 entity +- **Layer A** (`retrieval.text2cypher`) — top-N / 집계 / 필터 / 메타데이터 비교 +- **Layer B** (`retrieval.local_retrieve`) — 관계 / 연관 / co-mention / 인과 추론 - **Layer C** (`retrieval.community_summary`) — 글로벌 / 트렌드 / 전체 요약 (stub) +- **Layer A'** (`retrieval.bm25_retrieve`) — factual / numerical 단일사실 (어휘 fulltext) **[DEFAULT, 5/30 v3]** 흐름: 자연어 질문 @@ -25,7 +26,7 @@ 결정적 분기를 1단계로 두면 디버깅 / 평가 / 발표 시연 모두 명확. - **LLM fallback 은 필요한 만큼만**: 키워드 미매칭 시에만 호출 → 토큰 비용 최소화. 단순 factual 질문은 LLM 호출 없이 t2c 로 라우팅. -- **graceful default**: LLM 도 실패하면 → local (5/25 v2 갱신, 이전 t2c). +- **graceful default**: LLM 도 실패하면 → bm25 (5/30 v3 갱신, 이전 local). 5/25 v2 박힘: - DEFAULT_ROUTE: "t2c" → "local" 변경 @@ -34,15 +35,22 @@ - t2c 는 명백한 top-N / 집계 / 필터 / 메타데이터 비교에만 제한 - dryrun_80qa 결과 (VectorRAG 5/5 t2c rows=0 실패) 박제 후 결정 +5/30 v3 박힘: +- DEFAULT_ROUTE: "local" → "bm25" 변경 (factual/numerical 단일사실 재라우팅) +- ROUTER_PROMPT_PATH: router_v2.md → router_v3.md (bm25 갈래 추가) +- 근거: chunk-rerank 네거티브(#70) 진단 — graph traversal/MENTIONS 병목을 BM25 + 어휘검색으로 우회. 관계/인과는 키워드 라우터가 local로 잡으므로 default는 bm25. + 키워드 매핑 (5/17 결정): - "관계 / 관련 / 연관 / 영향 / 함께 / 이웃 / 어떻게" → **local** - "트렌드 / 전체 / 흐름 / 요약 / community / 글로벌 / 패턴 / 주제" → **community** -- 그 외 (factual / 몇 / 개수 / top / 특정 문서 등) → **local** (v2 default) +- 그 외 (factual / 몇 / 개수 / top / 특정 문서 등) → **bm25** (v3 default) 향후 확장 (5/24+): - Semantic router (embedding 기반) 로 키워드 매칭의 어휘 한계 보완 가능 - LLM fallback 의 신뢰도 임계 (현재 binary) 를 점수화하여 dual-path 합성도 가능 - Conversation history 반영 (현재는 stateless) +- Plan B: bm25 ⊕ local RRF 융합 (Cormack 2009, k=60) #24 Opik: - 공개 진입점 `route_and_answer` 에 `@track` — 질문/라우팅/소요 trace @@ -55,6 +63,7 @@ - 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 분류 +- BEIR (Thakur 2021) — BM25 robust OOD baseline (bm25 갈래 근거) """ from __future__ import annotations @@ -80,12 +89,13 @@ from retrieval.community_summary import CommunitySummaryResult, community_summary from retrieval.local_retriever import LocalRetrieverResult, local_retrieve from retrieval.text2cypher import Text2CypherResult, text2cypher +from retrieval.bm25_retriever import BM25RetrieverResult, bm25_retrieve logger = logging.getLogger(__name__) # ── 타입 ───────────────────────────────────────────────────── -Route = Literal["t2c", "local", "community"] +Route = Literal["t2c", "local", "community", "bm25"] # ── 키워드 매핑 (5/17 결정) ────────────────────────────────── @@ -127,11 +137,14 @@ # 기본 라우트 — 키워드 + LLM 모두 결정 못하면 여기로 # 5/25 v2: "t2c" → "local" 변경. 단일 entity 사실 질의가 압도적으로 많음을 반영. -DEFAULT_ROUTE: Route = "local" +# 5/30 v3: "local" → "bm25" 변경. chunk-rerank 네거티브(#70)가 가리킨 처방 — +# factual/numerical 단일사실은 graph traversal보다 BM25 어휘검색이 답 청크를 +# 직접 끌어옴. 관계/인과는 키워드 라우터가 local로 잡으므로 default는 bm25. +DEFAULT_ROUTE: Route = "bm25" -# 프롬프트 경로 — 5/25 v2: router_v1.md → router_v2.md +# 프롬프트 경로 — 5/25 v2: router_v1.md → router_v2.md, 5/30 v3: → router_v3.md (bm25 추가) PROMPTS_DIR = Path(__file__).parent / "prompts" -ROUTER_PROMPT_PATH = PROMPTS_DIR / "router_v2.md" +ROUTER_PROMPT_PATH = PROMPTS_DIR / "router_v3.md" # LLM fallback 설정 TEMPERATURE_ROUTER = 0.0 # 라우팅은 결정적 — 가장 낮게 @@ -167,6 +180,7 @@ class RoutedResult: t2c_result: Text2CypherResult | None = None local_result: LocalRetrieverResult | None = None community_result: CommunitySummaryResult | None = None + bm25_result: BM25RetrieverResult | None = None elapsed_seconds: float = 0.0 @@ -180,8 +194,8 @@ def _classify_by_keywords(question: str) -> RouteDecision: - 둘 다 매칭 → 더 많은 쪽 (동률이면 community 우선 — 전체 질의는 보통 community 의도) - 아무것도 매칭 안 됨 → route=None 의미로 빈 RouteDecision (LLM fallback 으로 갈 signal) - Note: 반환값의 route 가 DEFAULT_ROUTE (= "local", 5/25 v2) 면 두 가지 의미일 수 있음: - - 매칭된 키워드 있음 + 결과적으로 local 이 더 적합 (지금은 없음) + Note: 반환값의 route 가 DEFAULT_ROUTE (= "bm25", 5/30 v3) 면 두 가지 의미일 수 있음: + - 매칭된 키워드 있음 + 결과적으로 적합 (지금은 없음) - 매칭 0개 → caller 가 matched_keywords 비어 있는지로 LLM fallback 트리거 """ q = question.lower() if question else "" @@ -250,10 +264,10 @@ def _call_llm_router( def _classify_by_llm( llm: LLMClient, question: str, system_prompt: str ) -> RouteDecision: - """LLM 으로 라우팅 결정. 실패하면 default (local, 5/25 v2) 로 fallback. + """LLM 으로 라우팅 결정. 실패하면 default (bm25, 5/30 v3) 로 fallback. LLM 응답 예상 형식: - {"route": "t2c" | "local" | "community", "reasoning": "..."} + {"route": "t2c" | "local" | "community" | "bm25", "reasoning": "..."} """ user_prompt = f"질문: {question}" try: @@ -283,7 +297,7 @@ def _classify_by_llm( route = (data.get("route") or "").strip().lower() reasoning = (data.get("reasoning") or "").strip() - if route not in ("t2c", "local", "community"): + if route not in ("t2c", "local", "community", "bm25"): logger.warning("LLM router 가 알 수 없는 route 반환: %r — default(%s)", route, DEFAULT_ROUTE) return RouteDecision( @@ -307,7 +321,7 @@ def decide_route( ) -> RouteDecision: """질문 → RouteDecision (라우팅 결정만, retriever 호출은 X). - 1단계 키워드 분기 → 매칭 0개면 2단계 LLM fallback. LLM 실패 시 default(local, 5/25 v2). + 1단계 키워드 분기 → 매칭 0개면 2단계 LLM fallback. LLM 실패 시 default(bm25, 5/30 v3). Args: question: 사용자 자연어 질문. @@ -349,15 +363,15 @@ def route_and_answer( """자연어 질문 → 라우팅 → 해당 retriever 호출 → 통합 답변. DoD (#21): - - 키워드 기반 분기 (관계 → local, 트렌드 → community, 기타 → local) ✅ + - 키워드 기반 분기 (관계 → local, 트렌드 → community, 기타 → bm25) ✅ - LLM fallback (키워드 매칭 0개 시) ✅ - - graceful default — LLM 실패 시 local (5/25 v2) ✅ + - graceful default — LLM 실패 시 bm25 (5/30 v3) ✅ - 통합 진입점으로 발표 슬라이드 14 데모 가능 ✅ Args: question: 사용자 자연어 질문. llm: LLMClient (테스트용 mock 주입 가능). 라우팅 + 하위 retriever 양쪽에 전달. - neo4j: Neo4jClient (테스트용 mock 주입 가능). t2c / local 에 전달. + neo4j: Neo4jClient (테스트용 mock 주입 가능). t2c / local / bm25 에 전달. Returns: RoutedResult — 라우팅 결정 + 해당 retriever 결과 + 통합 answer. @@ -388,6 +402,17 @@ def route_and_answer( ) # 2) 결정된 retriever 호출 + if decision.route == "bm25": + bm25_res = bm25_retrieve(question, llm=llm, neo4j=neo4j) + answer = bm25_res.answer + elapsed = time.perf_counter() - started + return RoutedResult( + question=question, + decision=decision, + answer=answer, + bm25_result=bm25_res, + elapsed_seconds=elapsed, + ) if decision.route == "t2c": t2c_res = text2cypher(question, llm=llm, neo4j=neo4j) answer = t2c_res.answer