feat(search): Phase 3 Ask pipeline (evidence + synthesis + /api/search/ask)
- 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>
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
"""하이브리드 검색 API — orchestrator (Phase 1.1: thin endpoint).
|
||||
"""하이브리드 검색 API — thin endpoint (Phase 3.1 이후).
|
||||
|
||||
retrieval / fusion / rerank 등 실제 로직은 services/search/* 모듈로 분리.
|
||||
이 파일은 mode 분기, 응답 직렬화, debug 응답 구성, BackgroundTask dispatch만 담당.
|
||||
실제 검색 파이프라인(retrieval → fusion → rerank → diversity → confidence)
|
||||
은 `services/search/search_pipeline.py::run_search()` 로 분리되어 있다.
|
||||
이 파일은 다음만 담당:
|
||||
- Pydantic 스키마 (SearchResult / SearchResponse / SearchDebug / DebugCandidate
|
||||
/ Citation / AskResponse / AskDebug)
|
||||
- `/search` endpoint wrapper (run_search 호출 + logger + telemetry + 직렬화)
|
||||
- `/ask` endpoint wrapper (Phase 3.3 에서 추가)
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
@@ -15,48 +20,11 @@ from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from core.utils import setup_logger
|
||||
from models.user import User
|
||||
from services.search import query_analyzer
|
||||
from services.search.fusion_service import (
|
||||
DEFAULT_FUSION,
|
||||
apply_soft_filter_boost,
|
||||
get_strategy,
|
||||
normalize_display_scores,
|
||||
)
|
||||
from services.search.rerank_service import (
|
||||
MAX_CHUNKS_PER_DOC,
|
||||
MAX_RERANK_INPUT,
|
||||
apply_diversity,
|
||||
rerank_chunks,
|
||||
)
|
||||
from services.search.retrieval_service import (
|
||||
compress_chunks_to_docs,
|
||||
search_text,
|
||||
search_vector,
|
||||
search_vector_multilingual,
|
||||
)
|
||||
from services.search_telemetry import (
|
||||
compute_confidence,
|
||||
compute_confidence_hybrid,
|
||||
compute_confidence_reranked,
|
||||
record_search_event,
|
||||
)
|
||||
|
||||
|
||||
# Phase 2.1: analyzer_confidence 3단계 게이트 (값 조정은 plan 기준)
|
||||
ANALYZER_TIER_IGNORE = 0.5 # < 0.5 → analyzer 완전 무시, soft_filter 비활성
|
||||
ANALYZER_TIER_ORIGINAL = 0.7 # < 0.7 → original query fallback
|
||||
ANALYZER_TIER_MERGE = 0.85 # < 0.85 → original + analyzed merge
|
||||
|
||||
|
||||
def _analyzer_tier(confidence: float) -> str:
|
||||
"""analyzer_confidence → 사용 tier 문자열. Phase 2.2/2.3에서 실제 분기용."""
|
||||
if confidence < ANALYZER_TIER_IGNORE:
|
||||
return "ignore"
|
||||
if confidence < ANALYZER_TIER_ORIGINAL:
|
||||
return "original_fallback"
|
||||
if confidence < ANALYZER_TIER_MERGE:
|
||||
return "merge"
|
||||
return "analyzed"
|
||||
from services.search.evidence_service import EvidenceItem, extract_evidence
|
||||
from services.search.fusion_service import DEFAULT_FUSION
|
||||
from services.search.search_pipeline import PipelineResult, run_search
|
||||
from services.search.synthesis_service import SynthesisResult, synthesize
|
||||
from services.search_telemetry import record_search_event
|
||||
|
||||
# logs/search.log + stdout 동시 출력 (Phase 0.4)
|
||||
logger = setup_logger("search")
|
||||
@@ -84,6 +52,10 @@ class SearchResult(BaseModel):
|
||||
chunk_id: int | None = None
|
||||
chunk_index: int | None = None
|
||||
section_title: str | None = None
|
||||
# Phase 3.1: reranker raw score 보존 (display score drift 방지).
|
||||
# rerank 경로를 탄 chunk에만 채워짐. normalize_display_scores는 이 필드를
|
||||
# 건드리지 않는다. Phase 3 evidence fast-path 판단에 사용.
|
||||
rerank_score: float | None = None
|
||||
|
||||
|
||||
# ─── Phase 0.4: 디버그 응답 스키마 ─────────────────────────
|
||||
@@ -126,6 +98,29 @@ def _to_debug_candidates(rows: list[SearchResult], n: int = 20) -> list[DebugCan
|
||||
]
|
||||
|
||||
|
||||
def _build_search_debug(pr: PipelineResult) -> SearchDebug:
|
||||
"""PipelineResult → SearchDebug (기존 search()의 debug 구성 블록 복사)."""
|
||||
return SearchDebug(
|
||||
timing_ms=pr.timing_ms,
|
||||
text_candidates=(
|
||||
_to_debug_candidates(pr.text_results)
|
||||
if pr.text_results or pr.mode != "vector"
|
||||
else None
|
||||
),
|
||||
vector_candidates=(
|
||||
_to_debug_candidates(pr.vector_results)
|
||||
if pr.vector_results or pr.mode in ("vector", "hybrid")
|
||||
else None
|
||||
),
|
||||
fused_candidates=(
|
||||
_to_debug_candidates(pr.results) if pr.mode == "hybrid" else None
|
||||
),
|
||||
confidence=pr.confidence_signal,
|
||||
notes=pr.notes,
|
||||
query_analysis=pr.query_analysis,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=SearchResponse)
|
||||
async def search(
|
||||
q: str,
|
||||
@@ -149,193 +144,34 @@ async def search(
|
||||
),
|
||||
debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
|
||||
timing: dict[str, float] = {}
|
||||
notes: list[str] = []
|
||||
text_results: list[SearchResult] = []
|
||||
vector_results: list[SearchResult] = [] # doc-level (압축 후, fusion 입력)
|
||||
raw_chunks: list[SearchResult] = [] # chunk-level (raw, Phase 1.3 reranker용)
|
||||
chunks_by_doc: dict[int, list[SearchResult]] = {} # Phase 1.3 reranker용 보존
|
||||
query_analysis: dict | None = None
|
||||
analyzer_confidence: float = 0.0
|
||||
analyzer_tier: str = "disabled"
|
||||
|
||||
t_total = time.perf_counter()
|
||||
|
||||
# Phase 2.1 (async 구조): QueryAnalyzer는 동기 호출 금지.
|
||||
# - cache hit → query_analysis 활용 (Phase 2.2/2.3 파이프라인 조건부)
|
||||
# - cache miss → 기존 경로 유지 + background task 트리거 (fire-and-forget)
|
||||
# 실측(gemma-4 10초+) 기반 결정. memory: feedback_analyzer_async_only.md
|
||||
analyzer_cache_hit: bool = False
|
||||
if analyze:
|
||||
query_analysis = query_analyzer.get_cached(q)
|
||||
if query_analysis is not None:
|
||||
analyzer_cache_hit = True
|
||||
try:
|
||||
analyzer_confidence = float(
|
||||
query_analysis.get("analyzer_confidence", 0.0) or 0.0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
analyzer_confidence = 0.0
|
||||
analyzer_tier = _analyzer_tier(analyzer_confidence)
|
||||
notes.append(
|
||||
f"analyzer cache_hit conf={analyzer_confidence:.2f} tier={analyzer_tier}"
|
||||
)
|
||||
else:
|
||||
# cache miss → background analyzer 트리거 (retrieval 차단 X)
|
||||
triggered = query_analyzer.trigger_background_analysis(q)
|
||||
analyzer_tier = "cache_miss"
|
||||
notes.append(
|
||||
"analyzer cache_miss"
|
||||
+ (" (bg triggered)" if triggered else " (bg inflight)")
|
||||
)
|
||||
|
||||
# Phase 2.2: multilingual vector search 활성 조건 (보수적)
|
||||
# - cache hit + analyzer_tier == "analyzed" (≥0.85 고신뢰)
|
||||
# - normalized_queries 2개 이상 (lang 다양성 있음)
|
||||
# - domain_hint == "news" 또는 language_scope == "global"
|
||||
# ↑ 1차 측정 결과: document 도메인에서 multilingual이 natural_language_ko
|
||||
# -0.10 악화시킴. 영어 번역이 한국어 법령 검색에서 noise로 작용.
|
||||
# news / global 영역에서만 multilingual 활성 (news_crosslingual +0.10 개선 확인).
|
||||
use_multilingual: bool = False
|
||||
normalized_queries: list[dict] = []
|
||||
if analyzer_cache_hit and analyzer_tier == "analyzed" and query_analysis:
|
||||
domain_hint = query_analysis.get("domain_hint", "mixed")
|
||||
language_scope = query_analysis.get("language_scope", "limited")
|
||||
is_multilingual_candidate = (
|
||||
domain_hint == "news" or language_scope == "global"
|
||||
)
|
||||
if is_multilingual_candidate:
|
||||
raw_nq = query_analysis.get("normalized_queries") or []
|
||||
if isinstance(raw_nq, list) and len(raw_nq) >= 2:
|
||||
normalized_queries = [
|
||||
nq for nq in raw_nq if isinstance(nq, dict) and nq.get("text")
|
||||
]
|
||||
if len(normalized_queries) >= 2:
|
||||
use_multilingual = True
|
||||
notes.append(
|
||||
f"multilingual langs={[nq.get('lang') for nq in normalized_queries]}"
|
||||
f" hint={domain_hint}/{language_scope}"
|
||||
)
|
||||
|
||||
if mode == "vector":
|
||||
t0 = time.perf_counter()
|
||||
if use_multilingual:
|
||||
raw_chunks = await search_vector_multilingual(session, normalized_queries, limit)
|
||||
else:
|
||||
raw_chunks = await search_vector(session, q, limit)
|
||||
timing["vector_ms"] = (time.perf_counter() - t0) * 1000
|
||||
if not raw_chunks:
|
||||
notes.append("vector_search_returned_empty (AI client error or no embeddings)")
|
||||
# vector 단독 모드도 doc 압축해서 다양성 확보 (chunk 중복 방지)
|
||||
vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
|
||||
results = vector_results
|
||||
else:
|
||||
t0 = time.perf_counter()
|
||||
text_results = await search_text(session, q, limit)
|
||||
timing["text_ms"] = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if mode == "hybrid":
|
||||
t1 = time.perf_counter()
|
||||
if use_multilingual:
|
||||
raw_chunks = await search_vector_multilingual(session, normalized_queries, limit)
|
||||
else:
|
||||
raw_chunks = await search_vector(session, q, limit)
|
||||
timing["vector_ms"] = (time.perf_counter() - t1) * 1000
|
||||
|
||||
# chunk-level → doc-level 압축 (raw chunks는 chunks_by_doc에 보존)
|
||||
t1b = time.perf_counter()
|
||||
vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
|
||||
timing["compress_ms"] = (time.perf_counter() - t1b) * 1000
|
||||
|
||||
if not vector_results:
|
||||
notes.append("vector_search_returned_empty — text-only fallback")
|
||||
|
||||
t2 = time.perf_counter()
|
||||
strategy = get_strategy(fusion)
|
||||
# fusion은 doc 기준 — 더 넓게 가져옴 (rerank 후보용)
|
||||
fusion_limit = max(limit * 5, 100) if rerank else limit
|
||||
fused_docs = strategy.fuse(text_results, vector_results, q, fusion_limit)
|
||||
timing["fusion_ms"] = (time.perf_counter() - t2) * 1000
|
||||
notes.append(f"fusion={strategy.name}")
|
||||
notes.append(
|
||||
f"chunks raw={len(raw_chunks)} compressed={len(vector_results)} "
|
||||
f"unique_docs={len(chunks_by_doc)}"
|
||||
)
|
||||
|
||||
# Phase 2.3: soft_filter boost (cache hit + tier != ignore 일 때만)
|
||||
# analyzer_confidence < 0.5 (tier=ignore)는 비활성.
|
||||
if (
|
||||
analyzer_cache_hit
|
||||
and analyzer_tier != "ignore"
|
||||
and query_analysis
|
||||
):
|
||||
soft_filters = query_analysis.get("soft_filters") or {}
|
||||
if soft_filters:
|
||||
boosted = apply_soft_filter_boost(fused_docs, soft_filters)
|
||||
if boosted > 0:
|
||||
notes.append(f"soft_filter_boost applied={boosted}")
|
||||
|
||||
if rerank:
|
||||
# Phase 1.3: reranker — chunk 기준 입력
|
||||
# fusion 결과 doc_id로 chunks_by_doc에서 raw chunks 회수
|
||||
t3 = time.perf_counter()
|
||||
rerank_input: list[SearchResult] = []
|
||||
for doc in fused_docs:
|
||||
chunks = chunks_by_doc.get(doc.id, [])
|
||||
if chunks:
|
||||
# doc당 max 2 chunk (latency/VRAM 보호)
|
||||
rerank_input.extend(chunks[:MAX_CHUNKS_PER_DOC])
|
||||
else:
|
||||
# text-only 매치 doc → doc 자체를 chunk처럼 wrap
|
||||
rerank_input.append(doc)
|
||||
if len(rerank_input) >= MAX_RERANK_INPUT:
|
||||
break
|
||||
rerank_input = rerank_input[:MAX_RERANK_INPUT]
|
||||
notes.append(f"rerank input={len(rerank_input)}")
|
||||
|
||||
reranked = await rerank_chunks(q, rerank_input, limit * 3)
|
||||
timing["rerank_ms"] = (time.perf_counter() - t3) * 1000
|
||||
|
||||
# diversity (chunk → doc 압축, max_per_doc=2, top score>0.90 unlimited)
|
||||
t4 = time.perf_counter()
|
||||
results = apply_diversity(reranked, max_per_doc=MAX_CHUNKS_PER_DOC)[:limit]
|
||||
timing["diversity_ms"] = (time.perf_counter() - t4) * 1000
|
||||
else:
|
||||
# rerank 비활성: fused_docs를 그대로 (limit 적용)
|
||||
results = fused_docs[:limit]
|
||||
else:
|
||||
results = text_results
|
||||
|
||||
# display score 정규화 — 프론트엔드는 score*100을 % 표시.
|
||||
# fusion 내부 score(RRF는 0.01~0.05 범위)를 그대로 노출하면 표시가 깨짐.
|
||||
normalize_display_scores(results)
|
||||
|
||||
timing["total_ms"] = (time.perf_counter() - t_total) * 1000
|
||||
|
||||
# confidence는 fusion 적용 전 raw 신호로 계산 (Phase 0.5 이후 fused score는 절대값 의미 없음)
|
||||
# rerank 활성 시 reranker score가 가장 신뢰할 수 있는 신호 → 우선 사용
|
||||
if mode == "hybrid":
|
||||
if rerank and "rerank_ms" in timing:
|
||||
confidence_signal = compute_confidence_reranked(results)
|
||||
else:
|
||||
confidence_signal = compute_confidence_hybrid(text_results, vector_results)
|
||||
elif mode == "vector":
|
||||
confidence_signal = compute_confidence(vector_results, "vector")
|
||||
else:
|
||||
confidence_signal = compute_confidence(text_results, mode)
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 3.1 이후 run_search wrapper)"""
|
||||
pr = await run_search(
|
||||
session,
|
||||
q,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
limit=limit,
|
||||
fusion=fusion,
|
||||
rerank=rerank,
|
||||
analyze=analyze,
|
||||
)
|
||||
|
||||
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
|
||||
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
|
||||
timing_str = " ".join(f"{k}={v:.0f}" for k, v in pr.timing_ms.items())
|
||||
fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
|
||||
analyzer_str = (
|
||||
f" analyzer=hit={analyzer_cache_hit}/conf={analyzer_confidence:.2f}/tier={analyzer_tier}"
|
||||
f" analyzer=hit={pr.analyzer_cache_hit}/conf={pr.analyzer_confidence:.2f}/tier={pr.analyzer_tier}"
|
||||
if analyze
|
||||
else ""
|
||||
)
|
||||
logger.info(
|
||||
"search query=%r mode=%s%s%s results=%d conf=%.2f %s",
|
||||
q[:80], mode, fusion_str, analyzer_str, len(results), confidence_signal, timing_str,
|
||||
q[:80],
|
||||
pr.mode,
|
||||
fusion_str,
|
||||
analyzer_str,
|
||||
len(pr.results),
|
||||
pr.confidence_signal,
|
||||
timing_str,
|
||||
)
|
||||
|
||||
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
|
||||
@@ -344,28 +180,259 @@ async def search(
|
||||
record_search_event,
|
||||
q,
|
||||
user.id,
|
||||
results,
|
||||
mode,
|
||||
confidence_signal,
|
||||
analyzer_confidence if analyze else None,
|
||||
pr.results,
|
||||
pr.mode,
|
||||
pr.confidence_signal,
|
||||
pr.analyzer_confidence if analyze else None,
|
||||
)
|
||||
|
||||
debug_obj: SearchDebug | None = None
|
||||
if debug:
|
||||
debug_obj = SearchDebug(
|
||||
timing_ms=timing,
|
||||
text_candidates=_to_debug_candidates(text_results) if text_results or mode != "vector" else None,
|
||||
vector_candidates=_to_debug_candidates(vector_results) if vector_results or mode in ("vector", "hybrid") else None,
|
||||
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
|
||||
confidence=confidence_signal,
|
||||
notes=notes,
|
||||
query_analysis=query_analysis,
|
||||
)
|
||||
debug_obj = _build_search_debug(pr) if debug else None
|
||||
|
||||
return SearchResponse(
|
||||
results=results,
|
||||
total=len(results),
|
||||
results=pr.results,
|
||||
total=len(pr.results),
|
||||
query=q,
|
||||
mode=mode,
|
||||
mode=pr.mode,
|
||||
debug=debug_obj,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 3.3: /api/search/ask — Evidence + Grounded Synthesis
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
"""answer 본문의 [n] 에 해당하는 근거 단일 행."""
|
||||
|
||||
n: int
|
||||
chunk_id: int | None
|
||||
doc_id: int
|
||||
title: str | None
|
||||
section_title: str | None
|
||||
span_text: str # evidence LLM 이 추출한 50~300자
|
||||
full_snippet: str # 원본 800자 (citation 원문 보기 전용)
|
||||
relevance: float
|
||||
rerank_score: float
|
||||
|
||||
|
||||
class AskDebug(BaseModel):
|
||||
"""`/ask?debug=true` 응답 확장."""
|
||||
|
||||
timing_ms: dict[str, float]
|
||||
search_notes: list[str]
|
||||
query_analysis: dict | None = None
|
||||
confidence_signal: float
|
||||
evidence_candidate_count: int
|
||||
evidence_kept_count: int
|
||||
evidence_skip_reason: str | None
|
||||
synthesis_cache_hit: bool
|
||||
synthesis_prompt_preview: str | None = None
|
||||
synthesis_raw_preview: str | None = None
|
||||
hallucination_flags: list[str] = []
|
||||
|
||||
|
||||
class AskResponse(BaseModel):
|
||||
"""`/ask` 응답. `/search` 의 SearchResult 는 그대로 재사용."""
|
||||
|
||||
results: list[SearchResult]
|
||||
ai_answer: str | None
|
||||
citations: list[Citation]
|
||||
synthesis_status: Literal[
|
||||
"completed", "timeout", "skipped", "no_evidence", "parse_failed", "llm_error"
|
||||
]
|
||||
synthesis_ms: float
|
||||
confidence: Literal["high", "medium", "low"] | None
|
||||
refused: bool
|
||||
no_results_reason: str | None
|
||||
query: str
|
||||
total: int
|
||||
debug: AskDebug | None = None
|
||||
|
||||
|
||||
def _map_no_results_reason(
|
||||
pr: PipelineResult,
|
||||
evidence: list[EvidenceItem],
|
||||
ev_skip: str | None,
|
||||
sr: SynthesisResult,
|
||||
) -> str | None:
|
||||
"""사용자에게 보여줄 한국어 메시지 매핑.
|
||||
|
||||
Failure mode 표 (plan §Failure Modes) 기반.
|
||||
"""
|
||||
# LLM 자가 refused → 모델이 준 사유 그대로
|
||||
if sr.refused and sr.refuse_reason:
|
||||
return sr.refuse_reason
|
||||
|
||||
# synthesis 상태 우선
|
||||
if sr.status == "no_evidence":
|
||||
if not pr.results:
|
||||
return "검색 결과가 없습니다."
|
||||
return "관련도 높은 근거를 찾지 못했습니다."
|
||||
if sr.status == "skipped":
|
||||
return "검색 결과가 없습니다."
|
||||
if sr.status == "timeout":
|
||||
return "답변 생성이 지연되어 생략했습니다. 검색 결과를 확인해 주세요."
|
||||
if sr.status == "parse_failed":
|
||||
return "답변 형식 오류로 생략했습니다."
|
||||
if sr.status == "llm_error":
|
||||
return "AI 서버에 일시적 문제가 있습니다."
|
||||
|
||||
# evidence 단계 실패는 fallback 을 탔더라도 notes 용
|
||||
if ev_skip == "all_low_rerank":
|
||||
return "관련도 높은 근거를 찾지 못했습니다."
|
||||
if ev_skip == "empty_retrieval":
|
||||
return "검색 결과가 없습니다."
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_citations(
|
||||
evidence: list[EvidenceItem], used_citations: list[int]
|
||||
) -> list[Citation]:
|
||||
"""answer 본문에 실제로 등장한 n 만 Citation 으로 변환."""
|
||||
by_n = {e.n: e for e in evidence}
|
||||
out: list[Citation] = []
|
||||
for n in used_citations:
|
||||
e = by_n.get(n)
|
||||
if e is None:
|
||||
continue
|
||||
out.append(
|
||||
Citation(
|
||||
n=e.n,
|
||||
chunk_id=e.chunk_id,
|
||||
doc_id=e.doc_id,
|
||||
title=e.title,
|
||||
section_title=e.section_title,
|
||||
span_text=e.span_text,
|
||||
full_snippet=e.full_snippet,
|
||||
relevance=e.relevance,
|
||||
rerank_score=e.rerank_score,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _build_ask_debug(
|
||||
pr: PipelineResult,
|
||||
evidence: list[EvidenceItem],
|
||||
ev_skip: str | None,
|
||||
sr: SynthesisResult,
|
||||
ev_ms: float,
|
||||
synth_ms: float,
|
||||
total_ms: float,
|
||||
) -> AskDebug:
|
||||
timing: dict[str, float] = dict(pr.timing_ms)
|
||||
timing["evidence_ms"] = ev_ms
|
||||
timing["synthesis_ms"] = synth_ms
|
||||
timing["ask_total_ms"] = total_ms
|
||||
|
||||
# candidate count 는 rule filter 통과한 수 (recomputable from results)
|
||||
# 엄밀히는 evidence_service 내부 숫자인데, evidence 길이 ≈ kept, candidate
|
||||
# 는 관측이 어려움 → kept 는 evidence 길이, candidate 는 별도 필드 없음.
|
||||
# 단순화: candidate_count = len(evidence) 를 상한 근사로 둠 (debug 전용).
|
||||
return AskDebug(
|
||||
timing_ms=timing,
|
||||
search_notes=pr.notes,
|
||||
query_analysis=pr.query_analysis,
|
||||
confidence_signal=pr.confidence_signal,
|
||||
evidence_candidate_count=len(evidence),
|
||||
evidence_kept_count=len(evidence),
|
||||
evidence_skip_reason=ev_skip,
|
||||
synthesis_cache_hit=sr.cache_hit,
|
||||
synthesis_prompt_preview=None, # 현재 synthesis_service 에서 노출 안 함
|
||||
synthesis_raw_preview=sr.raw_preview,
|
||||
hallucination_flags=sr.hallucination_flags,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ask", response_model=AskResponse)
|
||||
async def ask(
|
||||
q: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
background_tasks: BackgroundTasks,
|
||||
limit: int = Query(10, ge=1, le=20, description="synthesis 입력 상한"),
|
||||
debug: bool = Query(False, description="evidence/synthesis 중간 상태 노출"),
|
||||
):
|
||||
"""근거 기반 AI 답변 (Phase 3.3).
|
||||
|
||||
`/search` 와 동일한 검색 파이프라인을 거친 후 evidence extraction +
|
||||
grounded synthesis 를 추가한다. `mode`, `rerank`, `analyze` 는 품질 보장을
|
||||
위해 강제 고정 (hybrid / True / True).
|
||||
|
||||
실패 경로(timeout/parse_failed/refused/...) 에서도 `results` 는 항상 반환.
|
||||
"""
|
||||
t_total = time.perf_counter()
|
||||
|
||||
# 1. 검색 파이프라인 (run_search — /search 와 동일 로직, 단일 진실 소스)
|
||||
pr = await run_search(
|
||||
session,
|
||||
q,
|
||||
mode="hybrid",
|
||||
limit=limit,
|
||||
fusion=DEFAULT_FUSION,
|
||||
rerank=True,
|
||||
analyze=True,
|
||||
)
|
||||
|
||||
# 2. Evidence extraction (rule + LLM span select, 1 batched call)
|
||||
t_ev = time.perf_counter()
|
||||
evidence, ev_skip = await extract_evidence(q, pr.results)
|
||||
ev_ms = (time.perf_counter() - t_ev) * 1000
|
||||
|
||||
# 3. Grounded synthesis (gemma-4, 15s timeout, citation 검증)
|
||||
t_synth = time.perf_counter()
|
||||
sr = await synthesize(q, evidence, debug=debug)
|
||||
synth_ms = (time.perf_counter() - t_synth) * 1000
|
||||
|
||||
total_ms = (time.perf_counter() - t_total) * 1000
|
||||
|
||||
# 4. 응답 구성
|
||||
citations = _build_citations(evidence, sr.used_citations)
|
||||
no_reason = _map_no_results_reason(pr, evidence, ev_skip, sr)
|
||||
|
||||
logger.info(
|
||||
"ask query=%r results=%d evidence=%d cite=%d synth=%s conf=%s refused=%s ev_ms=%.0f synth_ms=%.0f total=%.0f",
|
||||
q[:80],
|
||||
len(pr.results),
|
||||
len(evidence),
|
||||
len(citations),
|
||||
sr.status,
|
||||
sr.confidence or "-",
|
||||
sr.refused,
|
||||
ev_ms,
|
||||
synth_ms,
|
||||
total_ms,
|
||||
)
|
||||
|
||||
# 5. telemetry — 기존 record_search_event 재사용 (Phase 0.3 호환)
|
||||
background_tasks.add_task(
|
||||
record_search_event,
|
||||
q,
|
||||
user.id,
|
||||
pr.results,
|
||||
"hybrid",
|
||||
pr.confidence_signal,
|
||||
pr.analyzer_confidence,
|
||||
)
|
||||
|
||||
debug_obj = (
|
||||
_build_ask_debug(pr, evidence, ev_skip, sr, ev_ms, synth_ms, total_ms)
|
||||
if debug
|
||||
else None
|
||||
)
|
||||
|
||||
return AskResponse(
|
||||
results=pr.results,
|
||||
ai_answer=sr.answer,
|
||||
citations=citations,
|
||||
synthesis_status=sr.status,
|
||||
synthesis_ms=sr.elapsed_ms,
|
||||
confidence=sr.confidence,
|
||||
refused=sr.refused,
|
||||
no_results_reason=no_reason,
|
||||
query=q,
|
||||
total=len(pr.results),
|
||||
debug=debug_obj,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user