"""검색 실패 자동 로깅 (Phase 0.3) 목적: gold dataset 시드 수집. 평가셋 확장의 재료. 자동 수집 트리거: 1) result_count == 0 → no_result 2) confidence < THRESHOLD → low_confidence 3) 60초 내 동일 사용자 재쿼리 → user_reformulated (이전 쿼리 기록) confidence는 Phase 0.3 시점엔 휴리스틱(top score + match_reason 기반). Phase 2 QueryAnalyzer 도입 후 LLM 기반 confidence로 교체될 예정. ⚠ 단일 fastapi 워커 가정: recent_searches 트래커는 in-memory dict. 멀티 워커로 확장 시 user_reformulated 신호가 일부 손실되지만 정확성에는 영향 없음. """ from __future__ import annotations import asyncio import logging import time from dataclasses import dataclass from typing import Any from sqlalchemy.exc import SQLAlchemyError from core.database import async_session from models.search_failure import SearchFailureLog logger = logging.getLogger("search_telemetry") # ─── 튜닝 파라미터 ───────────────────────────────────── LOW_CONFIDENCE_THRESHOLD = 0.5 REFORMULATION_WINDOW_SEC = 60.0 TRACKER_MAX_USERS = 1000 # 인메모리 트래커 상한 (LRU-ish 정리) # ─── 인메모리 최근 쿼리 트래커 ───────────────────────── @dataclass class _RecentSearch: query: str normalized: str ts: float # monotonic seconds _recent: dict[int, _RecentSearch] = {} _recent_lock = asyncio.Lock() def _normalize(query: str) -> str: return " ".join(query.lower().strip().split()) async def _record_and_get_prior( user_id: int, query: str ) -> _RecentSearch | None: """현재 쿼리를 트래커에 기록하고, 60초 이내 직전 쿼리(있으면)를 반환.""" now = time.monotonic() normalized = _normalize(query) async with _recent_lock: prior = _recent.get(user_id) # 60초 초과한 prior는 무효 if prior and (now - prior.ts) > REFORMULATION_WINDOW_SEC: prior = None _recent[user_id] = _RecentSearch(query=query, normalized=normalized, ts=now) # 단순 상한 정리 (oldest 절반 제거) if len(_recent) > TRACKER_MAX_USERS: stale = sorted(_recent.items(), key=lambda kv: kv[1].ts)[: TRACKER_MAX_USERS // 2] for uid, _ in stale: _recent.pop(uid, None) return prior # ─── confidence 휴리스틱 ───────────────────────────────── def compute_confidence(results: list[Any], mode: str) -> float: """검색 결과로부터 confidence(0..1)를 휴리스틱으로 산정. Phase 0.3 임시 구현. Phase 2에서 QueryAnalyzer 결과 + reranker score로 교체. score 의미 정리 (search.py 기준): - mode=vector → score = 코사인 유사도 [0..1] - mode=fts/trgm/hybrid에서 텍스트 매치 → score = 가중치 합산 (unbounded) 가중치: title=3.0 / tags=2.5 / note=2.0 / summary=1.5 / content=1.0 / fts bonus≈2.0 - mode=hybrid에서 텍스트 0건 → 벡터 결과만, score는 코사인 그대로 - mode=hybrid 텍스트+벡터 동시 매치 → score = 텍스트가중치 + 0.5*코사인, match_reason = "<텍스트reason>+vector" 핵심: match_reason이 정확히 'vector'(=문자열 "vector")면 텍스트 매치 0건인 vector-only. 이 경우 score는 raw 코사인이므로 amplify 금지. """ if not results: return 0.0 top = results[0] top_score = float(getattr(top, "score", 0.0) or 0.0) reason = (getattr(top, "match_reason", "") or "").lower() if mode == "vector": # 코사인 유사도 그대로 return _cosine_to_confidence(top_score) # hybrid에서 텍스트+벡터 합성 매치는 reason에 "+vector" 접미. 신뢰 가산. has_vector_boost = "+vector" in reason boost = 0.10 if has_vector_boost else 0.0 # text / hybrid: 강한 텍스트 매치 우선 판정. # 임계값은 search.py의 가중치 합산 분포(텍스트base + FTS bonus + 0.5*cosine)를 반영. if "title" in reason and top_score >= 3.5: return min(1.0, 0.95 + boost) if any(k in reason for k in ("tags", "note")) and top_score >= 2.5: return min(1.0, 0.85 + boost) if "summary" in reason and top_score >= 2.0: return min(1.0, 0.75 + boost) if "content" in reason and top_score >= 1.5: return min(1.0, 0.65 + boost) if "fts" in reason and top_score >= 1.0: return min(1.0, 0.55 + boost) # vector-only hit (텍스트 0건 → 코사인 raw, amplify 금지) if reason == "vector": return _cosine_to_confidence(top_score) # 그 외(약한 매치 또는 알 수 없는 reason) return 0.3 def _cosine_to_confidence(cosine: float) -> float: """bge-m3 임베딩 코사인 유사도 → confidence 환산. bge-m3는 무관한 텍스트도 보통 0.3~0.5 정도 코사인을 만든다. 따라서 0.5는 "약하게 닮음", 0.7+는 "꽤 관련", 0.85+는 "매우 관련"으로 본다. """ if cosine >= 0.85: return 0.95 if cosine >= 0.75: return 0.80 if cosine >= 0.65: return 0.65 if cosine >= 0.55: return 0.50 # threshold 경계 if cosine >= 0.45: return 0.35 if cosine >= 0.35: return 0.20 return 0.10 def compute_confidence_hybrid( text_results: list[Any], vector_results: list[Any], ) -> float: """hybrid 모드 confidence — fusion 적용 *전*의 raw text/vector 결과로 계산. Phase 0.5에서 RRF 도입 후 fused score는 절대값 의미가 사라지므로, 원본 retrieval 신호의 더 강한 쪽을 confidence로 채택. """ text_conf = compute_confidence(text_results, "fts") if text_results else 0.0 vector_conf = ( compute_confidence(vector_results, "vector") if vector_results else 0.0 ) return max(text_conf, vector_conf) # ─── 로깅 진입점 ───────────────────────────────────────── async def _insert_log( query: str, user_id: int | None, result_count: int, confidence: float | None, failure_reason: str, context: dict[str, Any] | None, ) -> None: """단독 세션으로 INSERT (background task에서 호출되므로 request 세션 사용 불가).""" try: async with async_session() as session: row = SearchFailureLog( query=query, user_id=user_id, result_count=result_count, confidence=confidence, failure_reason=failure_reason, context=context, ) session.add(row) await session.commit() except SQLAlchemyError as exc: # 로깅 실패가 검색 자체를 깨뜨리지 않도록 흡수 logger.warning(f"failure log insert failed: {exc}") def _build_context( results: list[Any], mode: str, extra: dict[str, Any] | None = None, ) -> dict[str, Any]: ctx: dict[str, Any] = { "mode": mode, "result_count": len(results), "top_score": float(results[0].score) if results else None, "top_match_reason": (results[0].match_reason if results else None), "returned_ids": [r.id for r in results[:10]], } if extra: ctx.update(extra) return ctx async def record_search_event( query: str, user_id: int | None, results: list[Any], mode: str, confidence: float | None = None, ) -> None: """검색 응답 직후 호출. 실패 트리거에 해당하면 로그 INSERT. background task에서 await로 호출. request 세션과 분리. user_id가 None이면 reformulation 추적 + 로깅 모두 스킵 (시스템 호출 등). confidence 파라미터: - None이면 results 기준으로 자체 계산 (legacy 호출용). - 명시적으로 전달되면 그 값 사용 (Phase 0.5+: fusion 적용 전 raw 신호 기준). """ if user_id is None: return if confidence is None: confidence = compute_confidence(results, mode) result_count = len(results) base_ctx = _build_context(results, mode, extra={"confidence": confidence}) # ── 1) reformulation 체크 (이전 쿼리가 있으면 그걸 로깅) ── prior = await _record_and_get_prior(user_id, query) if prior and prior.normalized != _normalize(query): await _insert_log( query=prior.query, user_id=user_id, result_count=-1, # prior의 result_count는 알 수 없음(요청 세션 끝남) confidence=None, failure_reason="user_reformulated", context={"reformulated_to": query, "elapsed_sec": time.monotonic() - prior.ts}, ) # ── 2) 현재 쿼리에 대한 실패 트리거 ── if result_count == 0: await _insert_log( query=query, user_id=user_id, result_count=0, confidence=0.0, failure_reason="no_result", context=base_ctx, ) return if confidence < LOW_CONFIDENCE_THRESHOLD: await _insert_log( query=query, user_id=user_id, result_count=result_count, confidence=confidence, failure_reason="low_confidence", context=base_ctx, )