Files
hyungi_document_server/app/services/search/llm_gate.py
Hyungi Ahn 64322e4f6f 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>
2026-04-09 07:34:08 +09:00

59 lines
2.6 KiB
Python

"""MLX single-inference 전역 gate (Phase 3.1.1).
Mac mini MLX primary(gemma-4-26b-a4b-it-8bit)는 **single-inference**다.
동시 호출이 들어오면 queue가 폭발한다(실측: 23 concurrent 요청 → 22개 15초 timeout).
이 모듈은 analyzer / evidence / synthesis 등 **모든 MLX-bound LLM 호출**이
공유하는 `asyncio.Semaphore(1)`를 제공한다. MLX를 호출하는 경로는 예외 없이
`async with get_mlx_gate():` 블록 안에서만 `AIClient._call_chat(ai.primary, ...)`
를 호출해야 한다.
## 영구 룰
- **MLX primary 호출 경로는 예외 없이 gate 획득 필수**. query_analyzer /
evidence_service / synthesis_service 세 곳이 현재 사용자. 이후 경로가 늘어도
동일 gate를 import해서 사용한다. 새 Semaphore를 만들지 말 것 (큐 분할 시
동시 실행 발생).
- **`asyncio.timeout(...)`은 gate 안쪽에서만 적용**. gate 대기 자체에 timeout을
걸면 "대기만으로 timeout 발동" 버그가 재발한다(query_analyzer 초기 이슈).
- **fallback(Ollama) 경로는 gate 제외**. GPU Ollama는 concurrent OK. 단 현재
구현상 `AIClient._call_chat` 내부에서 primary→fallback 전환이 일어나므로
fallback도 gate 점유 상태로 실행된다. 허용 가능(fallback 빈도 낮음).
- **MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**. 모델이 바뀌어도 single-
inference 특성이 깨지지 않는 한 이 값을 올리지 말 것.
## 확장 여지 (지금은 구현하지 않음)
트래픽 증가 시 "우선순위 역전"(/ask가 analyzer background task 뒤에 밀림)이
문제가 되면 `asyncio.PriorityQueue` 기반 우선순위 큐로 교체 가능. Gate 자체
분리(get_analyzer_gate / get_ask_gate)는 single-inference에서 throughput
개선이 없으므로 의미 없음.
"""
from __future__ import annotations
import asyncio
# MLX primary는 single-inference → 1
MLX_CONCURRENCY = 1
# 첫 호출 시 현재 event loop에 바인딩된 Semaphore 생성 (lazy init)
_mlx_gate: asyncio.Semaphore | None = None
def get_mlx_gate() -> asyncio.Semaphore:
"""MLX primary 호출 경로 공용 gate. 최초 호출 시 lazy init.
사용 예:
async with get_mlx_gate():
async with asyncio.timeout(LLM_TIMEOUT_MS / 1000):
raw = await ai_client._call_chat(ai_client.ai.primary, prompt)
⚠ `asyncio.timeout`은 반드시 gate 안쪽에 둘 것. 바깥에 두면 gate 대기만으로
timeout이 발동한다.
"""
global _mlx_gate
if _mlx_gate is None:
_mlx_gate = asyncio.Semaphore(MLX_CONCURRENCY)
return _mlx_gate