feat(search): Phase 2.1 QueryAnalyzer + LRU cache + confidence 3-tier
QueryAnalyzer 스켈레톤 구현. 자연어 쿼리를 구조화된 분석 결과로 변환. Phase 2.1은 debug 노출 + tier 판정까지만 — retrieval 경로는 변경 X (회귀 0 목표). multilingual/filter 실제 분기는 2.2/2.3에서 이 분석 결과를 활용. app/prompts/query_analyze.txt - gemma-4 JSON-only 응답 규약 - intent/query_type/domain_hint/language_scope/normalized_queries/ hard_filters/soft_filters/expanded_terms/analyzer_confidence - 4가지 예시 (자연어 법령, 정확 조항, 뉴스 다국어, 의미 불명) - classify.txt 구조 참고 app/services/search/query_analyzer.py - LLM_TIMEOUT_MS=800 (MLX 멈춤 시 검색 전체 멈춤 방지, 절대 늘리지 말 것) - MAX_NORMALIZED_QUERIES=3 (multilingual explosion 방지) - in-memory FIFO LRU (maxsize=1000, TTL=86400) - cache key = sha256(query + PROMPT_VERSION + primary.model) → 모델/프롬프트 변경 시 자동 invalidate - 저신뢰(<0.5) / 실패 결과 캐시 금지 - weight 합=1.0 정규화 (fusion 왜곡 방지) - 실패 시 analyzer_confidence=float 0.0 (None 금지, TypeError 방지) app/api/search.py - ?analyze=true|false 파라미터 (default False — 회귀 영향 0) - query_analyzer.analyze() 호출 + timing["analyze_ms"] 기록 - _analyzer_tier(conf) → "ignore" | "original_fallback" | "merge" | "analyzed" (tier 게이트: 0.5 / 0.7 / 0.85) - debug.query_analysis 필드 채움 + notes에 tier/fallback_reason - logger 라인에 analyzer conf/tier 병기 app/services/search_telemetry.py - record_search_event(analyzer_confidence=None) 추가 - base_ctx에 analyzer_confidence 기록 (다층 confidence 시드) - result confidence와 분리된 축 — Phase 2.2+에서 failure 분류에 활용 검증: - python3 -m py_compile 통과 - 런타임 검증은 GPU 재배포 후 수행 (fixed 7 query + 평가셋) 참조: ~/.claude/plans/zesty-painting-kahan.md (Phase 2.1 섹션) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ 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, get_strategy, normalize_display_scores
|
||||
from services.search.rerank_service import (
|
||||
MAX_CHUNKS_PER_DOC,
|
||||
@@ -30,6 +31,23 @@ from services.search_telemetry import (
|
||||
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"
|
||||
|
||||
# logs/search.log + stdout 동시 출력 (Phase 0.4)
|
||||
logger = setup_logger("search")
|
||||
|
||||
@@ -115,6 +133,10 @@ async def search(
|
||||
True,
|
||||
description="bge-reranker-v2-m3 활성화 (Phase 1.3, hybrid 모드만 동작)",
|
||||
),
|
||||
analyze: bool = Query(
|
||||
False,
|
||||
description="QueryAnalyzer 활성화 (Phase 2.1, LLM 호출). Phase 2.1은 debug 노출만, 검색 경로 영향 X",
|
||||
),
|
||||
debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
|
||||
@@ -124,9 +146,37 @@ async def search(
|
||||
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: QueryAnalyzer — debug 노출 전용 (retrieval 경로는 변경 X)
|
||||
# Phase 2.2/2.3에서 multilingual + filter 분기 구현 시 활용.
|
||||
if analyze:
|
||||
t_analyze = time.perf_counter()
|
||||
try:
|
||||
query_analysis = await query_analyzer.analyze(q)
|
||||
except Exception as exc:
|
||||
logger.warning("query_analyzer raised: %r", exc)
|
||||
query_analysis = None
|
||||
timing["analyze_ms"] = (time.perf_counter() - t_analyze) * 1000
|
||||
if query_analysis:
|
||||
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 conf={analyzer_confidence:.2f} tier={analyzer_tier}"
|
||||
)
|
||||
fallback_reason = query_analysis.get("_fallback_reason")
|
||||
if fallback_reason:
|
||||
notes.append(f"analyzer_fallback={fallback_reason}")
|
||||
|
||||
if mode == "vector":
|
||||
t0 = time.perf_counter()
|
||||
raw_chunks = await search_vector(session, q, limit)
|
||||
@@ -218,14 +268,26 @@ async def search(
|
||||
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
|
||||
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
|
||||
fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
|
||||
analyzer_str = (
|
||||
f" analyzer=conf={analyzer_confidence:.2f}/tier={analyzer_tier}"
|
||||
if analyze
|
||||
else ""
|
||||
)
|
||||
logger.info(
|
||||
"search query=%r mode=%s%s results=%d conf=%.2f %s",
|
||||
q[:80], mode, fusion_str, len(results), confidence_signal, timing_str,
|
||||
"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,
|
||||
)
|
||||
|
||||
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
|
||||
# Phase 2.1: analyze=true일 때만 analyzer_confidence 전달 (False는 None → 기존 호환)
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, results, mode, confidence_signal
|
||||
record_search_event,
|
||||
q,
|
||||
user.id,
|
||||
results,
|
||||
mode,
|
||||
confidence_signal,
|
||||
analyzer_confidence if analyze else None,
|
||||
)
|
||||
|
||||
debug_obj: SearchDebug | None = None
|
||||
@@ -237,6 +299,7 @@ async def search(
|
||||
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
|
||||
confidence=confidence_signal,
|
||||
notes=notes,
|
||||
query_analysis=query_analysis,
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
|
||||
Reference in New Issue
Block a user