"""Evidence extraction 서비스 (Phase 3.2). reranker 결과 chunks 에서 query-relevant span 을 구조적으로 추출한다. ## 설계 (EV-A: Rule + LLM span select) ``` reranked results ↓ [rule filter] score >= 0.25, max_per_doc=2, top MAX_EVIDENCE_CANDIDATES ↓ [snippet 재윈도우] _extract_window(full, query, 800) — LLM 입력용 ↓ [1 batched LLM call] gemma-4 via get_mlx_gate() (single inference) ↓ [post-process] - relevance >= 0.5 필터 - span too-short (< 80자) → _extract_window(full, query, 120) 로 재확장 - span too-long (> 300자) → cut - doc-group ordering (검색 결과 doc 순서 유지, doc 내부만 relevance desc) - n 재부여 (1..N) ↓ EvidenceItem 리스트 ``` ## 영구 룰 - **LLM 호출은 1번만** (batched). 순차 호출 절대 금지 — MLX single-inference 큐가 폭발한다. - **모든 MLX 호출은 `get_mlx_gate()` 경유**. analyzer / synthesis 와 동일 semaphore 공유. - **fallback span 도 query 중심 window**. `full_snippet[:200]` 같은 "앞에서부터 자르기" 절대 금지. 조용한 품질 붕괴 (citation 은 멀쩡한데 실제 span 이 query 와 무관) 대표 사례. - **Span too-short 보정 필수**: `len(span) < 80` 이면 자동 확장. "짧을수록 정확" 이 아니라 **짧으면 위험** — synthesis LLM 이 문맥 부족으로 이어 만들기 (soft hallucination) 를 한다. - **Evidence ordering 은 doc-group 유지**. 전역 relevance desc 정렬 금지. answer 는 [1][2][3] 순서로 생성되고 그 순서가 문맥 흐름을 결정한다. ## 확장 여지 (지금은 비활성) `EVIDENCE_FAST_PATH_THRESHOLD` 가 `None` 이 아니고 `results[0].rerank_score >= THRESHOLD` 이면 LLM 호출 스킵 후 rule-only 경로로 즉시 반환. Activation 조건: (1) evidence LLM 호출 비율 > 80%, (2) /ask 평균 latency > 15s, (3) rerank top1 p50 > 0.75. 셋 다 충족해야 켠다. """ from __future__ import annotations import asyncio import time from dataclasses import dataclass, field from typing import TYPE_CHECKING from ai.client import AIClient, _load_prompt, parse_json_response from core.utils import setup_logger from .llm_gate import get_mlx_gate from .rerank_service import _extract_window if TYPE_CHECKING: from api.search import SearchResult logger = setup_logger("evidence") # ─── 상수 (plan 영구 룰) ───────────────────────────────── EVIDENCE_MIN_RERANK = 0.25 # 1차 rule cut — rerank score 이 미만은 제외 MAX_EVIDENCE_CANDIDATES = 6 # LLM 입력 상한 MAX_PER_DOC = 2 CANDIDATE_SNIPPET_CHARS = 800 # LLM 이 볼 원문 창 크기 MIN_RELEVANCE_KEEP = 0.5 # LLM 출력 필터 SPAN_MIN_CHARS = 80 # 이 미만이면 window enlarge SPAN_ENLARGE_TARGET = 120 # enlarge 시 재윈도우 target_chars SPAN_MAX_CHARS = 300 # 이 초과면 cut (synthesis token budget 보호) LLM_TIMEOUT_MS = 15000 PROMPT_VERSION = "v1" # 확장 여지 — None 이면 비활성 (baseline). 실측 후 0.8 등으로 켠다. EVIDENCE_FAST_PATH_THRESHOLD: float | None = None # ─── 반환 타입 ─────────────────────────────────────────── @dataclass(slots=True) class EvidenceItem: """LLM 또는 rule fallback 이 추출한 단일 evidence span. n 은 doc-group ordering + relevance 정렬 후 1부터 재부여된다. `full_snippet` 은 **synthesis 프롬프트에 절대 포함 금지** — debug / citation 원문 보기 전용. """ n: int # 1-based, synthesis 프롬프트의 [n] 과 매핑 chunk_id: int | None doc_id: int title: str | None section_title: str | None span_text: str # LLM 추출 (또는 rule fallback) span, 80~300자 relevance: float # LLM 0~1 (fallback 시 rerank_score 복사) rerank_score: float # raw reranker 점수 full_snippet: str # 원본 800자 (debug/citation 전용, synthesis 금지) # ─── 프롬프트 로딩 (module 초기화 1회) ─────────────────── try: EVIDENCE_PROMPT = _load_prompt("evidence_extract.txt") except FileNotFoundError: EVIDENCE_PROMPT = "" logger.warning( "evidence_extract.txt not found — evidence_service will always use rule-only fallback" ) # ─── Helper: candidates → LLM 입력 블록 ────────────────── def _build_numbered_candidates( candidates: list["SearchResult"], query: str ) -> tuple[str, list[str]]: """LLM 프롬프트의 {numbered_candidates} 블록 + 재윈도우된 full_snippet 리스트. Returns: (block_str, full_snippets) — full_snippets[i] 는 1-based n=i+1 의 원문 """ lines: list[str] = [] full_snippets: list[str] = [] for i, c in enumerate(candidates, 1): title = (c.title or "").strip() raw_text = c.snippet or "" full = _extract_window(raw_text, query, target_chars=CANDIDATE_SNIPPET_CHARS) full_snippets.append(full) lines.append(f"[{i}] title: {title} / text: {full}") return "\n".join(lines), full_snippets # ─── Helper: span length 보정 ─────────────────────────── def _normalize_span(span: str, full: str, query: str) -> tuple[str, bool]: """span 을 SPAN_MIN_CHARS ~ SPAN_MAX_CHARS 범위로 보정. Returns: (normalized_span, was_expanded) - was_expanded=True 이면 "short_span_expanded" 로그 대상 """ s = (span or "").strip() expanded = False if len(s) < SPAN_MIN_CHARS: # soft hallucination 방어 — query 중심으로 window 재확장 s = _extract_window(full, query, target_chars=SPAN_ENLARGE_TARGET) expanded = True if len(s) > SPAN_MAX_CHARS: s = s[:SPAN_MAX_CHARS] return s, expanded # ─── Helper: doc-group ordering ───────────────────────── def _apply_doc_group_ordering( items: list[EvidenceItem], results: list["SearchResult"], ) -> list[EvidenceItem]: """검색 결과 doc 순서 유지 + doc 내부만 relevance desc + n 재부여. answer 는 [1][2][3] 순서로 생성되고 그 순서가 문맥 흐름을 결정한다. 전역 relevance desc 정렬은 "doc A span1 → doc B span1 → doc A span2" 처럼 튀면서 읽기 이상한 답변을 만든다. """ if not items: return [] doc_order: dict[int, int] = {} for idx, r in enumerate(results): if r.id not in doc_order: doc_order[r.id] = idx # 정렬: (doc 순서, -relevance) items.sort( key=lambda it: (doc_order.get(it.doc_id, 9999), -it.relevance) ) # n 재부여 for new_n, it in enumerate(items, 1): it.n = new_n return items # ─── Helper: rule-only fallback ───────────────────────── def _build_rule_only_evidence( candidates: list["SearchResult"], full_snippets: list[str], query: str, ) -> list[EvidenceItem]: """LLM 실패/timeout 시 rule-only 경로. ⚠ `full_snippet[:200]` 같은 앞자르기 금지. 반드시 `_extract_window` 로 query 중심 윈도우를 만든다. relevance 는 rerank_score 복사. """ items: list[EvidenceItem] = [] for i, (c, full) in enumerate(zip(candidates, full_snippets), 1): span = _extract_window(full, query, target_chars=200) # 정규화 (보통 여기서는 SPAN_MIN_CHARS 이상이지만 안전장치) span, _expanded = _normalize_span(span, full, query) items.append( EvidenceItem( n=i, chunk_id=c.chunk_id, doc_id=c.id, title=c.title, section_title=c.section_title, span_text=span, relevance=float(c.rerank_score or c.score or 0.0), rerank_score=float(c.rerank_score or c.score or 0.0), full_snippet=full, ) ) return items # ─── Core: extract_evidence ───────────────────────────── async def extract_evidence( query: str, results: list["SearchResult"], ai_client: AIClient | None = None, ) -> tuple[list[EvidenceItem], str | None]: """reranked results → EvidenceItem 리스트. Returns: (items, skip_reason) skip_reason ∈ {None, "empty_retrieval", "all_low_rerank", "fast_path", "llm_timeout_fallback_rule", "llm_error_fallback_rule", "parse_failed_fallback_rule", "all_llm_rejected"} - skip_reason 이 None 이 아니어도 items 는 비어있지 않을 수 있다 (fallback/fast_path 경로). """ if not results: return [], "empty_retrieval" # ── 1차 rule filter: rerank_score >= EVIDENCE_MIN_RERANK + max_per_doc ── candidates: list["SearchResult"] = [] per_doc: dict[int, int] = {} for r in results: raw_score = r.rerank_score if r.rerank_score is not None else r.score if raw_score is None or raw_score < EVIDENCE_MIN_RERANK: continue if per_doc.get(r.id, 0) >= MAX_PER_DOC: continue candidates.append(r) per_doc[r.id] = per_doc.get(r.id, 0) + 1 if len(candidates) >= MAX_EVIDENCE_CANDIDATES: break if not candidates: return [], "all_low_rerank" # ── Fast-path (현재 비활성) ───────────────────────── if EVIDENCE_FAST_PATH_THRESHOLD is not None: # ⚠ display score 가 아니라 raw rerank_score 로 판단. # normalize_display_scores 를 거친 r.score 는 frontend 용 리스케일 # 값이라 distribution drift 가능. fast-path 는 reranker raw 신호가 안전. top_rerank = ( results[0].rerank_score if results[0].rerank_score is not None else 0.0 ) if top_rerank is not None and top_rerank >= EVIDENCE_FAST_PATH_THRESHOLD: _block, full_snippets = _build_numbered_candidates(candidates, query) items = _build_rule_only_evidence(candidates, full_snippets, query) items = _apply_doc_group_ordering(items, results) logger.info( "evidence fast_path query=%r candidates=%d kept=%d top_rerank=%.2f", query[:80], len(candidates), len(items), top_rerank, ) return items, "fast_path" # ── LLM 호출 준비 ─────────────────────────────────── if not EVIDENCE_PROMPT: # 프롬프트 미로딩 → rule-only _block, full_snippets = _build_numbered_candidates(candidates, query) items = _build_rule_only_evidence(candidates, full_snippets, query) items = _apply_doc_group_ordering(items, results) logger.warning( "evidence prompt_not_loaded → rule fallback query=%r kept=%d", query[:80], len(items), ) return items, "llm_error_fallback_rule" block, full_snippets = _build_numbered_candidates(candidates, query) prompt = EVIDENCE_PROMPT.replace("{query}", query).replace( "{numbered_candidates}", block ) client_owned = False if ai_client is None: ai_client = AIClient() client_owned = True t_start = time.perf_counter() raw: str | None = None llm_error: str | None = None try: # ⚠ semaphore 대기는 timeout 바깥. timeout 은 실제 LLM 호출에만. async with get_mlx_gate(): async with asyncio.timeout(LLM_TIMEOUT_MS / 1000): raw = await ai_client._call_chat(ai_client.ai.primary, prompt) except asyncio.TimeoutError: llm_error = "timeout" except Exception as exc: llm_error = f"llm_error:{type(exc).__name__}" finally: if client_owned: try: await ai_client.close() except Exception: pass elapsed_ms = (time.perf_counter() - t_start) * 1000 # ── LLM 실패 → rule fallback ──────────────────────── if llm_error is not None: items = _build_rule_only_evidence(candidates, full_snippets, query) items = _apply_doc_group_ordering(items, results) logger.warning( "evidence LLM %s → rule fallback query=%r candidates=%d kept=%d elapsed_ms=%.0f", llm_error, query[:80], len(candidates), len(items), elapsed_ms, ) return items, "llm_timeout_fallback_rule" if llm_error == "timeout" else "llm_error_fallback_rule" parsed = parse_json_response(raw or "") if not isinstance(parsed, dict) or not isinstance(parsed.get("items"), list): items = _build_rule_only_evidence(candidates, full_snippets, query) items = _apply_doc_group_ordering(items, results) logger.warning( "evidence parse_failed → rule fallback query=%r raw=%r elapsed_ms=%.0f", query[:80], (raw or "")[:200], elapsed_ms, ) return items, "parse_failed_fallback_rule" # ── LLM 출력 파싱 ────────────────────────────────── short_span_expanded = 0 llm_items: list[EvidenceItem] = [] for entry in parsed["items"]: if not isinstance(entry, dict): continue try: n_raw = int(entry.get("n", 0)) except (TypeError, ValueError): continue if n_raw < 1 or n_raw > len(candidates): continue try: relevance = float(entry.get("relevance", 0.0) or 0.0) except (TypeError, ValueError): relevance = 0.0 if relevance < MIN_RELEVANCE_KEEP: continue span_raw = entry.get("span") if not isinstance(span_raw, str) or not span_raw.strip(): continue candidate = candidates[n_raw - 1] full = full_snippets[n_raw - 1] span, expanded = _normalize_span(span_raw, full, query) if expanded: short_span_expanded += 1 llm_items.append( EvidenceItem( n=n_raw, # doc-group ordering 에서 재부여됨 chunk_id=candidate.chunk_id, doc_id=candidate.id, title=candidate.title, section_title=candidate.section_title, span_text=span, relevance=relevance, rerank_score=float( candidate.rerank_score if candidate.rerank_score is not None else (candidate.score or 0.0) ), full_snippet=full, ) ) # ── LLM 이 전부 reject → rule fallback ────────────── if not llm_items: items = _build_rule_only_evidence(candidates, full_snippets, query) items = _apply_doc_group_ordering(items, results) logger.warning( "evidence all_llm_rejected → rule fallback query=%r elapsed_ms=%.0f", query[:80], elapsed_ms, ) return items, "all_llm_rejected" # ── doc-group ordering + n 재부여 ─────────────────── llm_items = _apply_doc_group_ordering(llm_items, results) logger.info( "evidence ok query=%r candidates=%d kept=%d short_span_expanded=%d elapsed_ms=%.0f", query[:80], len(candidates), len(llm_items), short_span_expanded, elapsed_ms, ) return llm_items, None