refactor(search): Phase 2.1 QueryAnalyzer를 async-only 구조로 전환
## 철학 수정 (실측 기반)
gemma-4-26b-a4b-it-8bit MLX 실측:
- full query_analyze.txt (prompt_tok=2406) → 10.5초
- max_tokens 축소 무효 (모델 자연 EOS 조기 종료)
- 쿼리 길이 영향 거의 없음 (프롬프트 자체가 지배)
→ 800ms timeout 가정은 13배 초과. 동기 호출 완전히 불가능.
따라서 QueryAnalyzer는 "즉시 실행하는 기능" → "미리 준비해두는 기능"으로
포지셔닝 변경. retrieval 경로에서 analyzer 동기 호출 **금지**.
## 구조
```
query → retrieval (항상 즉시)
↘ trigger_background_analysis (fire-and-forget)
→ analyze() [5초+] → cache 저장
다음 호출 (동일 쿼리) → get_cached() 히트 → Phase 2 파이프라인 활성화
```
## 변경 사항
### app/prompts/query_analyze.txt
- 5971 chars → 2403 chars (40%)
- 예시 4개 → 1개, 규칙 설명 축약
- 목표 prompt_tok 2406 → ~600 (1/4)
### app/services/search/query_analyzer.py
- LLM_TIMEOUT_MS 800 → 5000 (background이므로 여유 OK)
- PROMPT_VERSION v1 → v2 (cache auto-invalidate)
- get_cached / set_cached 유지 — retrieval 경로 O(1) 조회
- trigger_background_analysis(query) 신규 — 동기 함수, 즉시 반환, task 생성
- _PENDING set으로 task 참조 유지 (premature GC 방지)
- _INFLIGHT set으로 동일 쿼리 중복 실행 방지
- prewarm_analyzer() 신규 — startup에서 15~20 쿼리 미리 분석
- DEFAULT_PREWARM_QUERIES: 평가셋 fixed 7 + 법령 3 + 뉴스 2 + 실무 3
### app/api/search.py
- 기존 sync analyzer 호출 완전 제거
- analyze=True → get_cached(q) 조회만 O(1)
- hit: query_analysis 활용 (Phase 2.2/2.3 파이프라인 조건부 활성화)
- miss: trigger_background_analysis(q) + 기존 경로 그대로
- timing["analyze_ms"] 제거 (경로에 LLM 호출 없음)
- notes에 analyzer cache_hit/cache_miss 상태 기록
- debug.query_analysis는 cache hit 시에만 채워짐
### app/main.py
- lifespan startup에 prewarm_analyzer() background task 추가
- 논블로킹 — 앱 시작 막지 않음
- delay_between=0.5로 MLX 부하 완화
## 기대 효과
- cold 요청 latency: 기존 Phase 1.3 그대로 (회귀 0)
- warm 요청 + prewarmed: cache hit → query_analysis 활용
- 예상 cache hit rate: 초기 70~80% (prewarm) + 사용 누적
- Phase 2.2/2.3 multilingual/filter 기능은 cache hit 시에만 동작
## 참조
- memory: feedback_analyzer_async_only.md (영구 룰 저장)
- plan: ~/.claude/plans/zesty-painting-kahan.md ("철학 수정" 섹션)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -152,17 +152,15 @@ async def search(
|
||||
|
||||
t_total = time.perf_counter()
|
||||
|
||||
# Phase 2.1: QueryAnalyzer — debug 노출 전용 (retrieval 경로는 변경 X)
|
||||
# Phase 2.2/2.3에서 multilingual + filter 분기 구현 시 활용.
|
||||
# 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:
|
||||
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:
|
||||
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
|
||||
@@ -171,11 +169,16 @@ async def search(
|
||||
analyzer_confidence = 0.0
|
||||
analyzer_tier = _analyzer_tier(analyzer_confidence)
|
||||
notes.append(
|
||||
f"analyzer conf={analyzer_confidence:.2f} tier={analyzer_tier}"
|
||||
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)")
|
||||
)
|
||||
fallback_reason = query_analysis.get("_fallback_reason")
|
||||
if fallback_reason:
|
||||
notes.append(f"analyzer_fallback={fallback_reason}")
|
||||
|
||||
if mode == "vector":
|
||||
t0 = time.perf_counter()
|
||||
@@ -269,7 +272,7 @@ async def search(
|
||||
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}"
|
||||
f" analyzer=hit={analyzer_cache_hit}/conf={analyzer_confidence:.2f}/tier={analyzer_tier}"
|
||||
if analyze
|
||||
else ""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user