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:
@@ -1,5 +1,275 @@
|
||||
"""Query analyzer — 자연어 쿼리 분석 (Phase 2).
|
||||
"""Query analyzer — 자연어 쿼리 분석 (Phase 2.1).
|
||||
|
||||
domain_hint, intent, hard/soft filter, normalized_queries 등 추출.
|
||||
구현은 Phase 2에서 채움.
|
||||
자연어 쿼리를 구조화된 분석 결과로 변환. 결과는 검색 보조용 (지배 X).
|
||||
|
||||
Pipeline:
|
||||
1. in-memory LRU 캐시 조회
|
||||
2. miss → LLM 호출 (primary 모델, asyncio.timeout 강제)
|
||||
3. JSON 파싱 → weight 정규화 → 캐시 저장
|
||||
4. 실패/저신뢰 → `{"analyzer_confidence": 0.0}` (fallback 트리거)
|
||||
|
||||
CRITICAL 룰 (plan 영구):
|
||||
- `LLM_TIMEOUT_MS = 800` 절대 늘리지 말 것. MLX 멈춤 시 검색 전체 멈춤 방지.
|
||||
- `MAX_NORMALIZED_QUERIES = 3` 절대 늘리지 말 것. multilingual explosion 방지.
|
||||
- weight 합 = 1.0 정규화 필수. fusion 왜곡 방지.
|
||||
- 실패/저신뢰(< 0.5) 결과는 캐시 금지. 잘못된 분석 고정 방지.
|
||||
- 호출자는 항상 `result.get("analyzer_confidence", 0.0)` 방어 패턴 사용.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ai.client import AIClient, _load_prompt, parse_json_response
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger("query_analyzer")
|
||||
|
||||
# ─── 상수 (plan 영구 룰) ────────────────────────────────
|
||||
PROMPT_VERSION = "v1" # prompts/query_analyze.txt 변경 시 갱신
|
||||
CACHE_TTL = 86400 # 24h
|
||||
CACHE_MAXSIZE = 1000
|
||||
LLM_TIMEOUT_MS = 800 # 절대 늘리지 말 것 (plan 영구)
|
||||
MIN_CACHE_CONFIDENCE = 0.5 # 이 미만은 캐시 금지
|
||||
MAX_NORMALIZED_QUERIES = 3 # 절대 늘리지 말 것 (plan 영구)
|
||||
|
||||
|
||||
def _model_version() -> str:
|
||||
"""현재 primary 모델 ID를 캐시 키에 반영.
|
||||
|
||||
런타임에 config.yaml이 바뀌어도 재시작 후 자동 반영.
|
||||
config.yaml 없을 경우 sentinel 문자열.
|
||||
"""
|
||||
if settings.ai and settings.ai.primary:
|
||||
return settings.ai.primary.model
|
||||
return "unknown-model"
|
||||
|
||||
|
||||
# ─── in-memory LRU (FIFO 근사) ──────────────────────────
|
||||
_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _cache_key(query: str) -> str:
|
||||
raw = f"{query}|{PROMPT_VERSION}|{_model_version()}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def get_cached(query: str) -> dict | None:
|
||||
"""TTL 경과 entry는 자동 삭제. 없으면 None."""
|
||||
key = _cache_key(query)
|
||||
entry = _CACHE.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() - entry["ts"] > CACHE_TTL:
|
||||
_CACHE.pop(key, None)
|
||||
return None
|
||||
return entry["value"]
|
||||
|
||||
|
||||
def set_cached(query: str, value: dict) -> None:
|
||||
"""저신뢰(< 0.5) / 빈 값은 캐시 금지. 상한 초과 시 FIFO eviction."""
|
||||
if not value:
|
||||
return
|
||||
try:
|
||||
conf = float(value.get("analyzer_confidence", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.0
|
||||
if conf < MIN_CACHE_CONFIDENCE:
|
||||
return
|
||||
key = _cache_key(query)
|
||||
if key in _CACHE:
|
||||
_CACHE[key] = {"value": value, "ts": time.time()}
|
||||
return
|
||||
if len(_CACHE) >= CACHE_MAXSIZE:
|
||||
# 가장 먼저 추가된 항목 제거 (dict insertion order 활용)
|
||||
try:
|
||||
oldest = next(iter(_CACHE))
|
||||
_CACHE.pop(oldest, None)
|
||||
except StopIteration:
|
||||
pass
|
||||
_CACHE[key] = {"value": value, "ts": time.time()}
|
||||
|
||||
|
||||
def cache_stats() -> dict[str, int]:
|
||||
"""debug용 — 현재 캐시 크기."""
|
||||
return {"size": len(_CACHE), "maxsize": CACHE_MAXSIZE}
|
||||
|
||||
|
||||
# ─── weight 정규화 (fusion 왜곡 방지) ───────────────────
|
||||
def _normalize_weights(analysis: dict) -> dict:
|
||||
"""normalized_queries를 MAX_NORMALIZED_QUERIES로 자르고 weight 합=1.0 정규화.
|
||||
|
||||
- 리스트가 없거나 비어 있으면 그대로 반환
|
||||
- 각 항목의 weight가 숫자 아니면 1.0으로 치환
|
||||
- 합이 0이면 균등 분배
|
||||
"""
|
||||
queries = analysis.get("normalized_queries")
|
||||
if not isinstance(queries, list) or not queries:
|
||||
return analysis
|
||||
|
||||
# sanitize + cap
|
||||
sanitized: list[dict] = []
|
||||
for q in queries[:MAX_NORMALIZED_QUERIES]:
|
||||
if not isinstance(q, dict):
|
||||
continue
|
||||
lang = str(q.get("lang", "")).strip() or "ko"
|
||||
text = str(q.get("text", "")).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
w = float(q.get("weight", 1.0))
|
||||
if w < 0:
|
||||
w = 0.0
|
||||
except (TypeError, ValueError):
|
||||
w = 1.0
|
||||
sanitized.append({"lang": lang, "text": text, "weight": w})
|
||||
|
||||
if not sanitized:
|
||||
return analysis
|
||||
|
||||
total = sum(q["weight"] for q in sanitized)
|
||||
if total <= 0:
|
||||
equal = 1.0 / len(sanitized)
|
||||
for q in sanitized:
|
||||
q["weight"] = equal
|
||||
else:
|
||||
for q in sanitized:
|
||||
q["weight"] /= total
|
||||
|
||||
analysis["normalized_queries"] = sanitized
|
||||
return analysis
|
||||
|
||||
|
||||
# ─── 프롬프트 로딩 (module 초기화 1회) ──────────────────
|
||||
try:
|
||||
ANALYZE_PROMPT = _load_prompt("query_analyze.txt")
|
||||
except FileNotFoundError:
|
||||
ANALYZE_PROMPT = ""
|
||||
logger.warning("query_analyze.txt not found — analyzer will always return fallback")
|
||||
|
||||
|
||||
# ─── 기본 fallback 응답 (None 금지) ─────────────────────
|
||||
def _fallback(reason: str | None = None) -> dict:
|
||||
"""LLM 실패/timeout/parse 실패 시 반환. analyzer_confidence는 반드시 float 0.0.
|
||||
|
||||
호출자는 `result.get("analyzer_confidence", 0.0)`로 방어.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"intent": "semantic_search",
|
||||
"query_type": "keyword",
|
||||
"domain_hint": "mixed",
|
||||
"language_scope": "limited",
|
||||
"keywords": [],
|
||||
"must_terms": [],
|
||||
"optional_terms": [],
|
||||
"hard_filters": {},
|
||||
"soft_filters": {"domain": [], "document_type": []},
|
||||
"normalized_queries": [],
|
||||
"expanded_terms": [],
|
||||
"synonyms": {},
|
||||
"analyzer_confidence": 0.0,
|
||||
}
|
||||
if reason:
|
||||
result["_fallback_reason"] = reason
|
||||
return result
|
||||
|
||||
|
||||
# ─── 메인 API ──────────────────────────────────────────
|
||||
async def analyze(query: str, ai_client: AIClient | None = None) -> dict:
|
||||
"""쿼리 분석 결과 반환. 실패 시 반드시 analyzer_confidence=0.0 fallback.
|
||||
|
||||
Args:
|
||||
query: 사용자 쿼리 원문
|
||||
ai_client: AIClient 인스턴스 (호출자가 싱글톤으로 관리. None이면 생성)
|
||||
|
||||
Returns:
|
||||
dict — 최소 `analyzer_confidence` 키는 항상 float로 존재.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return _fallback("empty_query")
|
||||
|
||||
if not ANALYZE_PROMPT:
|
||||
return _fallback("prompt_not_loaded")
|
||||
|
||||
# cache hit (고신뢰만 캐시되므로 그대로 반환)
|
||||
cached = get_cached(query)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# LLM 호출 — timeout 강제
|
||||
client_owned = False
|
||||
if ai_client is None:
|
||||
ai_client = AIClient()
|
||||
client_owned = True
|
||||
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
async with asyncio.timeout(LLM_TIMEOUT_MS / 1000):
|
||||
raw = await ai_client._call_chat(
|
||||
ai_client.ai.primary,
|
||||
ANALYZE_PROMPT.replace("{query}", query),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = (time.perf_counter() - t_start) * 1000
|
||||
logger.warning(
|
||||
"query_analyze timeout query=%r elapsed_ms=%.0f (LLM_TIMEOUT_MS=%d)",
|
||||
query[:80],
|
||||
elapsed,
|
||||
LLM_TIMEOUT_MS,
|
||||
)
|
||||
return _fallback("timeout")
|
||||
except Exception as exc:
|
||||
elapsed = (time.perf_counter() - t_start) * 1000
|
||||
logger.warning(
|
||||
"query_analyze LLM error query=%r elapsed_ms=%.0f err=%r",
|
||||
query[:80],
|
||||
elapsed,
|
||||
exc,
|
||||
)
|
||||
return _fallback(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
|
||||
|
||||
# JSON 파싱
|
||||
parsed = parse_json_response(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning(
|
||||
"query_analyze parse failed query=%r elapsed_ms=%.0f raw=%r",
|
||||
query[:80],
|
||||
elapsed_ms,
|
||||
(raw or "")[:200],
|
||||
)
|
||||
return _fallback("parse_failed")
|
||||
|
||||
# 필수 필드 방어 — analyzer_confidence는 반드시 float
|
||||
try:
|
||||
conf = float(parsed.get("analyzer_confidence", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.0
|
||||
parsed["analyzer_confidence"] = conf
|
||||
|
||||
# weight 정규화 (MAX_NORMALIZED_QUERIES + sum=1.0)
|
||||
parsed = _normalize_weights(parsed)
|
||||
|
||||
logger.info(
|
||||
"query_analyze ok query=%r conf=%.2f intent=%s domain=%s elapsed_ms=%.0f",
|
||||
query[:80],
|
||||
conf,
|
||||
parsed.get("intent"),
|
||||
parsed.get("domain_hint"),
|
||||
elapsed_ms,
|
||||
)
|
||||
|
||||
# 고신뢰만 캐시 저장
|
||||
set_cached(query, parsed)
|
||||
return parsed
|
||||
|
||||
Reference in New Issue
Block a user