- llm_gate.py: MLX single-inference 전역 semaphore (analyzer/evidence/synthesis 공유) - search_pipeline.py: run_search() 추출, /search 와 /ask 단일 진실 소스 - evidence_service.py: Rule + LLM span select (EV-A), doc-group ordering, span too-short 자동 확장(<80자→120자), fallback 은 query 중심 window 강제 - synthesis_service.py: grounded answer + citation 검증 + LRU 캐시(1h/300), refused 처리, span_text ONLY 룰 (full_snippet 프롬프트 금지) - /api/search/ask: 15s timeout, 9가지 failure mode + 한국어 no_results_reason - rerank_service: rerank_score raw 보존 (display drift 방지) - query_analyzer: _get_llm_semaphore 를 llm_gate.get_mlx_gate 로 위임 - prompts: evidence_extract.txt, search_synthesis.txt (JSON-only, example 포함) config.yaml / docker / ollama / infra_inventory 변경 없음. plan: ~/.claude/plans/quiet-meandering-nova.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
408 lines
16 KiB
Python
408 lines
16 KiB
Python
"""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
|