11 Commits

Author SHA1 Message Date
Hyungi Ahn
120db86d74 docs(search): Phase 2 최종 측정 보고서 (phase2_final.md + csv A/B)
## 결과 요약

Phase 1.3 baseline vs Phase 2 final A/B (평가셋 v0.1, 23 쿼리):
 - Recall@10:  0.730 → 0.737 (+0.007)
 - NDCG@10:    0.663 → 0.668 (+0.005)
 - Top-3 hit:  0.900 → 0.900 (0)
 - p95 latency: 171ms → 256ms (+85)
 - news_crosslingual NDCG: 0.27 → 0.37 (+0.10 ✓)
 - exact_keyword / natural_language_ko: 완전 유지 (회귀 0)

## Phase 2 게이트: 2/6 통과
 ✓ news_crosslingual NDCG ≥ 0.30
 ✓ latency p95 < 400ms
  Recall@10 ≥ 0.78 (0.737)
  Top-3 hit ≥ 0.93 (0.900)
  crosslingual_ko_en NDCG ≥ 0.65 (0.53, bge-m3 한계)
  평가셋 v0.2 작성 (후속)

## 핵심 성과 (게이트 미달이지만 견고한 기반)
 1. QueryAnalyzer async-only 아키텍처 (retrieval 차단 0)
 2. semaphore concurrency=1 (MLX single-inference queue 폭발 방지)
 3. multilingual narrowing (news/global 한정 → 회귀 0 + news 개선)
 4. soft_filter boost 보수적 설정 (0.01, domain only)
 5. prewarm 15개 → cache hit rate 70%+

## infra_inventory.md soft lock 준수
 - config.yaml / Ollama / compose restart 변경 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:52:21 +09:00
Hyungi Ahn
01f144ab25 fix(search): soft_filter boost 약화 (domain 0.01, doctype 제거)
## 1차 측정 결과 (Phase 2.3 초안)

| metric | Phase 2.2 narrow | Phase 2.3 (boost 0.03+0.02) | Δ |
|---|---|---|---|
| Recall@10 | 0.737 | 0.721 | -0.016  |
| NDCG@10 | 0.668 | 0.661 | -0.007 |
| exact_keyword NDCG | 0.96 | 0.93 | -0.03  |

## 진단
- 같은 도메인 doc이 **무차별** boost → exact match doc 상대 우위 손상
- document_type 매칭은 ai_domain/match_reason 휴리스틱 → false positive 다수

## 수정
- SOFT_FILTER_DOMAIN_BOOST 0.03 → **0.01**
- document_type 매칭 로직 제거
- domain 매칭을 "정확 일치 또는 path 포함"으로 좁힘
- max cap 0.05 유지

## Phase 2.3 위치
 - 현재 평가셋(v0.1)에는 filter 쿼리 없음 → 효과 직접 측정 불가
 - Phase 2.4에서 queries_v0.2.yaml 확장 후 재측정 예정
 - 이 커밋의 목적은 "회귀 방지" — boost가 해를 끼치지 않도록만

(+ CLAUDE.md 동기화: infra_inventory.md 참조 / soft lock 섹션 포함)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:40:04 +09:00
Hyungi Ahn
e91c199537 feat(search): Phase 2.3 soft_filter boost (domain/doctype)
## 변경

### fusion_service.py
 - SOFT_FILTER_MAX_BOOST = 0.05 (plan 영구 룰, RRF score 왜곡 방지)
 - SOFT_FILTER_DOMAIN_BOOST = 0.03, SOFT_FILTER_DOCTYPE_BOOST = 0.02
 - apply_soft_filter_boost(results, soft_filters) → int
   - ai_domain 부분 문자열 매칭 (path 포함 e.g. "Industrial_Safety/Legislation")
   - document_type 토큰 매칭 (ai_domain + match_reason 헤이스택)
   - 상한선 0.05 강제
   - boost 후 score 기준 재정렬

### api/search.py
 - fusion 직후 호출 조건:
   - analyzer_cache_hit == True
   - analyzer_tier != "ignore" (confidence >= 0.5)
   - query_analysis.soft_filters 존재
 - notes에 "soft_filter_boost applied=N" 기록

## Phase 2.3 범위
 - hard_filter SQL WHERE는 현재 평가셋에 명시 필터 쿼리 없어 효과 측정 불가 → Phase 2.4 v0.2 확장 후
 - document_type의 file_format 직접 매칭은 의미론적 mismatch → 제외
 - hard_filter는 Phase 2.4 이후 iteration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:30:23 +09:00
Hyungi Ahn
e595283e27 fix(search): Phase 2.2 multilingual 활성 조건을 news/global 한정으로 좁힘
## 1차 측정 결과

| metric | Phase 1.3 | Phase 2.2 (all domains) | Δ |
|---|---|---|---|
| Recall@10 | 0.730 | 0.683 | -0.047  |
| natural_language_ko NDCG | 0.73 | 0.63 | -0.10  |
| news_crosslingual NDCG | 0.27 | 0.37 | +0.10 ✓ |
| crosslingual_ko_en NDCG | 0.53 | 0.50 | -0.03  |

document 도메인에서 ko→en 번역 쿼리가 한국어 법령 검색에 noise로 작용.
"기계 사고 관련 법령" → "machinery accident laws" 영어 embedding이
한국어 법령 문서와 매칭 약해서 ko 결과를 오히려 밀어냄.

## 수정

use_multilingual 조건 강화:
 - 기존: analyzer_tier == "analyzed" + normalized_queries >= 2
 - 추가: domain_hint == "news" OR language_scope == "global"

즉 document 도메인은 기존 single-query 경로 유지 → 회귀 복구.
news / global 영역만 multilingual → news_crosslingual 개선 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:20:05 +09:00
Hyungi Ahn
21a78fbbf0 fix(search): semaphore로 LLM concurrency=1 강제 + run_eval analyze 파라미터 추가
## 배경
1차 Phase 2.2 eval에서 발견: 23개 쿼리가 순차 호출되지만 각 request의
background analyzer task는 모두 동시에 MLX에 요청 날림 → MLX single-inference
서버 queue 폭발 → 22개가 15초 timeout. cache 채워지지 않음.

## 수정

### query_analyzer.py
 - LLM_CONCURRENCY = 1 상수 추가
 - _LLM_SEMAPHORE: lazy init asyncio.Semaphore (event loop 바인딩)
 - analyze() 내부: semaphore → timeout(실제 LLM 호출만) 이중 래핑
   semaphore 대기 시간이 timeout에 포함되지 않도록 주의

### run_eval.py
 - --analyze true|false 파라미터 추가 (Phase 2.1+ 측정용)
 - call_search / evaluate 시그니처에 analyze 전달

## 기대 효과
 - prewarm/background/동기 호출 모두 1개씩 순차 MLX 호출
 - 23개 대기 시 최악 230초 소요, 단 모두 성공해서 cache 채움
 - MLX 서버 부하 안정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:12:13 +09:00
Hyungi Ahn
f5c3dea833 feat(search): Phase 2.2 multilingual vector retrieval + query embed cache
## 변경 사항

### app/services/search/retrieval_service.py
 - **_QUERY_EMBED_CACHE**: 모듈 레벨 LRU (maxsize=500, TTL=24h)
   - sha256(text|bge-m3) 키. fixed query 재호출 시 vector_ms 절반 감소.
 - **_get_query_embedding(client, text)**: cache-first helper. 기존 search_vector()도 이를 사용하도록 교체.
 - **search_vector_multilingual(session, normalized_queries, limit)**: 신규
   - normalized_queries 각 언어별 embedding 병렬 생성 (cache hit 활용)
   - 각 embedding에 대해 docs+chunks hybrid retrieval 병렬
   - weight 기반 score 누적 merge (lang_weight 이미 1.0 정규화)
   - match_reason에 "ml_ko+en" 등 언어 병합 표시
   - 호출 조건 문서화 — cache hit + analyzer_tier=analyzed 시에만

### app/api/search.py
 - use_multilingual 결정 로직:
   - analyzer_cache_hit == True
   - analyzer_tier == "analyzed" (confidence >= 0.85)
   - normalized_queries >= 2 (다언어 버전 실제 존재)
 - 위 3조건 모두 만족할 때만 search_vector_multilingual 호출
 - 그 외 모든 경로 (cache miss, low conf, single lang)는 기존 search_vector 그대로 사용 (회귀 0 보장)
 - notes에 `multilingual langs=[ko, en, ...]` 기록

## 기대 효과
 - crosslingual_ko_en NDCG 0.53 → 0.65+ (Phase 2 목표)
 - 기존 경로 완전 불변 → 회귀 0
 - Phase 2.1 async 구조와 결합해 "cache hit일 때만 활성" 조건 준수

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:59:20 +09:00
Hyungi Ahn
1e80d4c613 fix(search): query_analyzer가 setup_logger 사용하도록 수정
기본 logging.getLogger()는 WARNING 레벨이라 prewarm/analyze 진행 로그가
stdout/파일 어디에도 안 찍혔음. setup_logger("query_analyzer")로 교체하면
logs/query_analyzer.log + stdout 둘 다 INFO 레벨 출력.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:52:46 +09:00
Hyungi Ahn
324537cbc8 fix(search): LLM_TIMEOUT_MS 5000 → 15000 (실측 반영)
축소 프롬프트 재측정:
  - prompt_tok 2406 → 802 (1/3 감소 성공)
  - latency 10.5초 → 7~11초 (generation이 dominant)
  - max_tokens 내려도 무효 (자연 EOS ~289 tok)

5000ms로는 여전히 모든 prewarm timeout. async 구조이므로
background에서 15초 기다려도 retrieval 경로 영향 0.

추가: prewarm delay_between 0.5 → 0.2 (총 prewarm 시간 단축).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:50:56 +09:00
Hyungi Ahn
c81b728ddf 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>
2026-04-08 14:47:09 +09:00
Hyungi Ahn
d28ef2fca0 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>
2026-04-08 14:21:37 +09:00
Hyungi Ahn
de08735420 fix(ai): primary -> mlx-proxy 8801 + align model to gemma
- endpoint: 100.76.254.116:8800 -> :8801 (route through mlx-proxy for
  /status observability - active_jobs / total_requests)
- model: Qwen3.5-35B-A3B-4bit -> gemma-4-26b-a4b-it-8bit (match the
  model actually loaded on mlx-proxy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 04:40:06 +00:00
13 changed files with 1066 additions and 23 deletions

View File

@@ -1,5 +1,20 @@
# hyungi_Document_Server — Claude Code 작업 가이드 # hyungi_Document_Server — Claude Code 작업 가이드
## Infrastructure Reference 📌
**Always refer to** `~/.claude/projects/-Users-hyungiahn/memory/infra_inventory.md` for:
- AI model routing (primary / fallback / embedding / rerank / vision) — **the model names below may be stale**
- Machine info, Tailscale IPs, SSH targets
- Docker container topology and compose projects
- Drift log (known Desired vs Actual inconsistencies)
- Verify commands
**If this file and `infra_inventory.md` disagree, `infra_inventory.md` is authoritative.** Do not change `config.yaml` / `credentials.env` without first updating `infra_inventory.md`.
**Search experiment soft lock**: During Phase 2 work (search.py refactor, QueryAnalyzer, run_eval.py execution), do **not** run `docker compose restart`, change `config.yaml`, or pull Ollama models. Violating this invalidates the experiment baseline.
---
## 프로젝트 개요 ## 프로젝트 개요
Self-hosted PKM(Personal Knowledge Management) 웹 애플리케이션. Self-hosted PKM(Personal Knowledge Management) 웹 애플리케이션.

View File

@@ -15,14 +15,25 @@ from core.auth import get_current_user
from core.database import get_session from core.database import get_session
from core.utils import setup_logger from core.utils import setup_logger
from models.user import User from models.user import User
from services.search.fusion_service import DEFAULT_FUSION, get_strategy, normalize_display_scores from services.search import query_analyzer
from services.search.fusion_service import (
DEFAULT_FUSION,
apply_soft_filter_boost,
get_strategy,
normalize_display_scores,
)
from services.search.rerank_service import ( from services.search.rerank_service import (
MAX_CHUNKS_PER_DOC, MAX_CHUNKS_PER_DOC,
MAX_RERANK_INPUT, MAX_RERANK_INPUT,
apply_diversity, apply_diversity,
rerank_chunks, rerank_chunks,
) )
from services.search.retrieval_service import compress_chunks_to_docs, search_text, search_vector from services.search.retrieval_service import (
compress_chunks_to_docs,
search_text,
search_vector,
search_vector_multilingual,
)
from services.search_telemetry import ( from services.search_telemetry import (
compute_confidence, compute_confidence,
compute_confidence_hybrid, compute_confidence_hybrid,
@@ -30,6 +41,23 @@ from services.search_telemetry import (
record_search_event, 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) # logs/search.log + stdout 동시 출력 (Phase 0.4)
logger = setup_logger("search") logger = setup_logger("search")
@@ -115,6 +143,10 @@ async def search(
True, True,
description="bge-reranker-v2-m3 활성화 (Phase 1.3, hybrid 모드만 동작)", 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 응답에 포함"), debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
): ):
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)""" """문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
@@ -124,11 +156,73 @@ async def search(
vector_results: list[SearchResult] = [] # doc-level (압축 후, fusion 입력) vector_results: list[SearchResult] = [] # doc-level (압축 후, fusion 입력)
raw_chunks: list[SearchResult] = [] # chunk-level (raw, Phase 1.3 reranker용) raw_chunks: list[SearchResult] = [] # chunk-level (raw, Phase 1.3 reranker용)
chunks_by_doc: dict[int, list[SearchResult]] = {} # 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() t_total = time.perf_counter()
# 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:
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
)
except (TypeError, ValueError):
analyzer_confidence = 0.0
analyzer_tier = _analyzer_tier(analyzer_confidence)
notes.append(
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)")
)
# Phase 2.2: multilingual vector search 활성 조건 (보수적)
# - cache hit + analyzer_tier == "analyzed" (≥0.85 고신뢰)
# - normalized_queries 2개 이상 (lang 다양성 있음)
# - domain_hint == "news" 또는 language_scope == "global"
# ↑ 1차 측정 결과: document 도메인에서 multilingual이 natural_language_ko
# -0.10 악화시킴. 영어 번역이 한국어 법령 검색에서 noise로 작용.
# news / global 영역에서만 multilingual 활성 (news_crosslingual +0.10 개선 확인).
use_multilingual: bool = False
normalized_queries: list[dict] = []
if analyzer_cache_hit and analyzer_tier == "analyzed" and query_analysis:
domain_hint = query_analysis.get("domain_hint", "mixed")
language_scope = query_analysis.get("language_scope", "limited")
is_multilingual_candidate = (
domain_hint == "news" or language_scope == "global"
)
if is_multilingual_candidate:
raw_nq = query_analysis.get("normalized_queries") or []
if isinstance(raw_nq, list) and len(raw_nq) >= 2:
normalized_queries = [
nq for nq in raw_nq if isinstance(nq, dict) and nq.get("text")
]
if len(normalized_queries) >= 2:
use_multilingual = True
notes.append(
f"multilingual langs={[nq.get('lang') for nq in normalized_queries]}"
f" hint={domain_hint}/{language_scope}"
)
if mode == "vector": if mode == "vector":
t0 = time.perf_counter() t0 = time.perf_counter()
if use_multilingual:
raw_chunks = await search_vector_multilingual(session, normalized_queries, limit)
else:
raw_chunks = await search_vector(session, q, limit) raw_chunks = await search_vector(session, q, limit)
timing["vector_ms"] = (time.perf_counter() - t0) * 1000 timing["vector_ms"] = (time.perf_counter() - t0) * 1000
if not raw_chunks: if not raw_chunks:
@@ -143,6 +237,9 @@ async def search(
if mode == "hybrid": if mode == "hybrid":
t1 = time.perf_counter() t1 = time.perf_counter()
if use_multilingual:
raw_chunks = await search_vector_multilingual(session, normalized_queries, limit)
else:
raw_chunks = await search_vector(session, q, limit) raw_chunks = await search_vector(session, q, limit)
timing["vector_ms"] = (time.perf_counter() - t1) * 1000 timing["vector_ms"] = (time.perf_counter() - t1) * 1000
@@ -166,6 +263,19 @@ async def search(
f"unique_docs={len(chunks_by_doc)}" f"unique_docs={len(chunks_by_doc)}"
) )
# Phase 2.3: soft_filter boost (cache hit + tier != ignore 일 때만)
# analyzer_confidence < 0.5 (tier=ignore)는 비활성.
if (
analyzer_cache_hit
and analyzer_tier != "ignore"
and query_analysis
):
soft_filters = query_analysis.get("soft_filters") or {}
if soft_filters:
boosted = apply_soft_filter_boost(fused_docs, soft_filters)
if boosted > 0:
notes.append(f"soft_filter_boost applied={boosted}")
if rerank: if rerank:
# Phase 1.3: reranker — chunk 기준 입력 # Phase 1.3: reranker — chunk 기준 입력
# fusion 결과 doc_id로 chunks_by_doc에서 raw chunks 회수 # fusion 결과 doc_id로 chunks_by_doc에서 raw chunks 회수
@@ -218,14 +328,26 @@ async def search(
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다 # 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items()) timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
fusion_str = f" fusion={fusion}" if mode == "hybrid" else "" fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
analyzer_str = (
f" analyzer=hit={analyzer_cache_hit}/conf={analyzer_confidence:.2f}/tier={analyzer_tier}"
if analyze
else ""
)
logger.info( logger.info(
"search query=%r mode=%s%s results=%d conf=%.2f %s", "search query=%r mode=%s%s%s results=%d conf=%.2f %s",
q[:80], mode, fusion_str, len(results), confidence_signal, timing_str, q[:80], mode, fusion_str, analyzer_str, len(results), confidence_signal, timing_str,
) )
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task) # Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
# Phase 2.1: analyze=true일 때만 analyzer_confidence 전달 (False는 None → 기존 호환)
background_tasks.add_task( 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 debug_obj: SearchDebug | None = None
@@ -237,6 +359,7 @@ async def search(
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None, fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
confidence=confidence_signal, confidence=confidence_signal,
notes=notes, notes=notes,
query_analysis=query_analysis,
) )
return SearchResponse( return SearchResponse(

View File

@@ -20,8 +20,11 @@ from models.user import User
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 핸들러""" """앱 시작/종료 시 실행되는 lifespan 핸들러"""
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from services.search.query_analyzer import prewarm_analyzer
from workers.daily_digest import run as daily_digest_run from workers.daily_digest import run as daily_digest_run
from workers.file_watcher import watch_inbox from workers.file_watcher import watch_inbox
from workers.law_monitor import run as law_monitor_run from workers.law_monitor import run as law_monitor_run
@@ -54,6 +57,15 @@ async def lifespan(app: FastAPI):
scheduler.add_job(news_collector_run, "interval", hours=6, id="news_collector") scheduler.add_job(news_collector_run, "interval", hours=6, id="news_collector")
scheduler.start() scheduler.start()
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
# 대표 쿼리 15~20개를 background task로 분석해 cache 적재.
# 첫 사용자 요청부터 cache hit rate 70~80% 목표.
# 논블로킹 — startup을 막지 않음. MLX 부하 완화 위해 delay_between=0.5.
prewarm_task = asyncio.create_task(prewarm_analyzer())
prewarm_task.add_done_callback(
lambda t: t.exception() and None # 예외는 query_analyzer 내부에서 로깅
)
yield yield
# 종료: 스케줄러 → DB 순서로 정리 # 종료: 스케줄러 → DB 순서로 정리

View File

@@ -0,0 +1,53 @@
You are a search query analyzer. Respond ONLY in JSON. No markdown, no explanation.
## Output Schema
{
"intent": "fact_lookup | semantic_search | filter_browse",
"query_type": "natural_language | keyword | phrase",
"domain_hint": "document | news | mixed",
"language_scope": "limited | global",
"keywords": [],
"must_terms": [],
"optional_terms": [],
"hard_filters": {},
"soft_filters": {"domain": [], "document_type": []},
"normalized_queries": [{"lang": "ko", "text": "...", "weight": 1.0}],
"expanded_terms": [],
"synonyms": {},
"analyzer_confidence": 0.0
}
## Rules
- `intent`: fact_lookup (사실/조항/이름), semantic_search (주제/개념), filter_browse (필터 중심)
- `query_type`: natural_language (문장형), keyword (단어 나열), phrase (따옴표/고유명사/법조항)
- `domain_hint`: document (소유 문서/법령/매뉴얼), news (시사/뉴스), mixed (불명)
- `language_scope`: limited (ko+en), global (다국어 필요)
- `hard_filters`: 쿼리에 **명시된** 것만. 추론 금지. 키: file_format, year, country
- `soft_filters.domain`: Industrial_Safety, Programming, Engineering, Philosophy, Language, General. 2-level 허용(e.g. Industrial_Safety/Legislation)
- `soft_filters.document_type`: Law_Document, Manual, Report, Academic_Paper, Standard, Specification, Meeting_Minutes, Checklist, Note, Memo, Reference, Drawing, Template
- `normalized_queries`: 원문 언어 1.0 가중치 필수. 교차언어 1개 추가 권장(ko↔en, weight 0.8). news + global 인 경우만 ja/zh 추가(weight 0.5~0.6). **최대 3개**.
- `analyzer_confidence`: 0.9+ 명확, 0.7~0.9 대체로 명확, 0.5~0.7 모호, <0.5 분석 불가
## Example
query: `기계 사고 관련 법령`
{
"intent": "semantic_search",
"query_type": "natural_language",
"domain_hint": "document",
"language_scope": "limited",
"keywords": ["기계", "사고", "법령"],
"must_terms": [],
"optional_terms": ["안전", "규정"],
"hard_filters": {},
"soft_filters": {"domain": ["Industrial_Safety/Legislation"], "document_type": ["Law_Document"]},
"normalized_queries": [
{"lang": "ko", "text": "기계 사고 관련 법령", "weight": 1.0},
{"lang": "en", "text": "machinery accident related laws", "weight": 0.8}
],
"expanded_terms": ["산업안전", "기계안전"],
"synonyms": {},
"analyzer_confidence": 0.88
}
## Query
{query}

View File

@@ -219,6 +219,62 @@ def get_strategy(name: str) -> FusionStrategy:
return cls() return cls()
# ─── Phase 2.3: soft filter boost ───────────────────────
SOFT_FILTER_MAX_BOOST = 0.05 # plan 룰 (CRITICAL)
# ↑ RRF score는 0.01~0.05 범위 (k=60). 상한 초과 시 기존 랭킹 왜곡.
# 기존 RRFWithBoost의 legal article boost(0.05)와 동일 최대값 → 일관성.
SOFT_FILTER_DOMAIN_BOOST = 0.01 # 2026-04-08 실측: 0.03은 exact_keyword -0.03 악화
# ↑ 낮게 잡는 이유: soft_filter는 "같은 도메인 doc을 동등하게 boost" → exact match
# doc의 상대 우위가 손상됨. 0.01 수준이면 fusion 내부 순위 역전 확률 최소.
def apply_soft_filter_boost(
results: list["SearchResult"],
soft_filters: dict | None,
) -> int:
"""Phase 2.3 — QueryAnalyzer soft_filters.domain 기반 약한 score boost.
ai_domain 정확 매칭 시 SOFT_FILTER_DOMAIN_BOOST(0.01) 1회 가산.
document_type 매칭은 v0.1 평가셋에서 효과 측정 불가 + false positive 많음 → 제외.
Args:
results: fusion 직후 SearchResult 리스트 (in-place 수정)
soft_filters: query_analysis.soft_filters = {"domain": [...]}
Returns:
int — boost 적용된 결과 개수 (debug/notes용)
"""
if not soft_filters:
return 0
domain_list = [str(d).lower() for d in soft_filters.get("domain", []) or []]
if not domain_list:
return 0
boosted_count = 0
for r in results:
if not r.ai_domain:
continue
ai_dom_lower = r.ai_domain.lower()
# 정확 매칭 또는 subdirectory 매칭 ("Industrial_Safety/Legislation" → "industrial_safety" 매칭)
matched = False
for d in domain_list:
if d == ai_dom_lower:
matched = True
break
# path 레벨 매칭: "industrial_safety/legislation" in "industrial_safety/legislation/act"
if d in ai_dom_lower and "/" in d:
matched = True
break
if matched:
r.score += min(SOFT_FILTER_DOMAIN_BOOST, SOFT_FILTER_MAX_BOOST)
boosted_count += 1
# boost 적용 후 재정렬
results.sort(key=lambda x: x.score, reverse=True)
return boosted_count
# ─── display score 정규화 ──────────────────────────────── # ─── display score 정규화 ────────────────────────────────

View File

@@ -1,5 +1,442 @@
"""Query analyzer — 자연어 쿼리 분석 (Phase 2). """Query analyzer — 자연어 쿼리 분석 (Phase 2.1, async-only 구조).
domain_hint, intent, hard/soft filter, normalized_queries 등 추출. **핵심 철학** (memory `feedback_analyzer_async_only.md`):
구현은 Phase 2에서 채움. > QueryAnalyzer는 "즉시 실행하는 기능"이 아니라 "미리 준비해두는 기능"이다.
Retrieval 경로에서 analyzer를 **동기 호출 금지**.
동기 호출 가능한 API는 prewarm 전용.
## Pipeline
```
query → retrieval (항상 즉시)
↘ trigger_background_analysis (fire-and-forget)
→ analyze() [5초+] → cache 저장
다음 호출 (동일 쿼리) → get_cached() 히트 → Phase 2 파이프라인 활성화
```
## 룰 (plan 영구)
- `LLM_TIMEOUT_MS = 5000` (background 이므로 여유 OK)
- `MAX_NORMALIZED_QUERIES = 3` (multilingual explosion 방지)
- weight 합 = 1.0 정규화 필수 (fusion 왜곡 방지)
- 실패/저신뢰(< 0.5) 결과는 캐시 금지 (잘못된 분석 고정 방지)
- `analyzer_confidence` default `float 0.0` 강제 (None 금지)
- analyze() 동기 호출 금지. retrieval 경로는 `get_cached()` + `trigger_background_analysis()` 만 사용.
""" """
from __future__ import annotations
import asyncio
import hashlib
import time
from typing import Any
from ai.client import AIClient, _load_prompt, parse_json_response
from core.config import settings
from core.utils import setup_logger
logger = setup_logger("query_analyzer")
# ─── 상수 (plan 영구 룰) ────────────────────────────────
PROMPT_VERSION = "v2" # prompts/query_analyze.txt 축소판
CACHE_TTL = 86400 # 24h
CACHE_MAXSIZE = 1000
LLM_TIMEOUT_MS = 15000 # async 구조 (background), 동기 경로 금지
# ↑ 실측: gemma-4-26b-a4b-it-8bit MLX, 축소 프롬프트(prompt_tok=802) 7~11초.
# generation이 dominant (max_tokens 무효, 자연 EOS ~289 tok 생성).
# background 실행이라 15초도 안전. 상향 필요 시 여기서만 조정.
LLM_CONCURRENCY = 1 # MLX는 single-inference, 동시 호출 시 queue 폭발.
# ↑ 실측: 23 concurrent 요청 → 22개 15초 timeout. semaphore로 순차 강제.
# prewarm/background/동기 호출 모두 이 semaphore 경유.
MIN_CACHE_CONFIDENCE = 0.5 # 이 미만은 캐시 금지
MAX_NORMALIZED_QUERIES = 3
def _model_version() -> str:
"""현재 primary 모델 ID를 캐시 키에 반영."""
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]] = {}
# background task 참조 유지 (premature GC 방지)
_PENDING: set[asyncio.Task[Any]] = set()
# 동일 쿼리 중복 실행 방지 (진행 중인 쿼리 집합)
_INFLIGHT: set[str] = set()
# MLX concurrency 제한 (single-inference → 1)
# 첫 호출 시 lazy init (event loop이 준비된 후)
_LLM_SEMAPHORE: asyncio.Semaphore | None = None
def _get_llm_semaphore() -> asyncio.Semaphore:
"""첫 호출 시 현재 event loop에 바인딩된 semaphore 생성."""
global _LLM_SEMAPHORE
if _LLM_SEMAPHORE is None:
_LLM_SEMAPHORE = asyncio.Semaphore(LLM_CONCURRENCY)
return _LLM_SEMAPHORE
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.
retrieval 경로에서 cache hit 판단용으로 호출. 호출 자체는 O(1).
"""
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:
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/운영용 — 현재 캐시 크기 + inflight 수."""
return {
"size": len(_CACHE),
"maxsize": CACHE_MAXSIZE,
"inflight": len(_INFLIGHT),
"pending_tasks": len(_PENDING),
}
# ─── weight 정규화 (fusion 왜곡 방지) ───────────────────
def _normalize_weights(analysis: dict) -> dict:
"""normalized_queries를 MAX_NORMALIZED_QUERIES로 자르고 weight 합=1.0 정규화."""
queries = analysis.get("normalized_queries")
if not isinstance(queries, list) or not queries:
return analysis
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: 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
# ─── 메인 LLM 호출 (내부 사용) ──────────────────────────
async def analyze(query: str, ai_client: AIClient | None = None) -> dict:
"""쿼리 분석 결과 반환. 실패 시 analyzer_confidence=0.0 fallback.
**⚠️ 동기 검색 경로에서 직접 호출 금지**. 용도:
- `trigger_background_analysis` 내부 호출
- `prewarm_analyzer` startup 호출
- 디버깅/테스트
Args:
query: 사용자 쿼리 원문
ai_client: AIClient 인스턴스 (없으면 생성 후 자동 close)
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 즉시 반환 (prewarm 재호출 방지)
cached = get_cached(query)
if cached is not None:
return cached
client_owned = False
if ai_client is None:
ai_client = AIClient()
client_owned = True
t_start = time.perf_counter()
semaphore = _get_llm_semaphore()
# ⚠️ 중요: semaphore 대기는 timeout 포함되면 안됨 (대기만 해도 timeout 발동)
# timeout은 실제 LLM 호출 구간에만 적용.
try:
async with semaphore:
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
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")
try:
conf = float(parsed.get("analyzer_confidence", 0.0) or 0.0)
except (TypeError, ValueError):
conf = 0.0
parsed["analyzer_confidence"] = conf
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
# ─── Background trigger (retrieval 경로에서 사용) ───────
async def _background_analyze(query: str) -> None:
"""Background task wrapper — inflight 집합 관리 + 예외 삼킴."""
try:
await analyze(query)
except Exception as exc:
logger.warning("background analyze crashed query=%r err=%r", query[:80], exc)
finally:
_INFLIGHT.discard(query)
def trigger_background_analysis(query: str) -> bool:
"""retrieval 경로에서 호출. cache miss 시 background task 생성.
- 동기 함수. 즉시 반환.
- 이미 inflight 또는 cache hit이면 아무 작업 X, False 반환.
- 새 task 생성 시 True 반환.
Returns:
bool — task 실제로 생성되었는지 여부
"""
if not query or not query.strip():
return False
if query in _INFLIGHT:
return False
if get_cached(query) is not None:
return False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.warning("trigger_background_analysis called outside event loop")
return False
_INFLIGHT.add(query)
task = loop.create_task(_background_analyze(query))
_PENDING.add(task)
task.add_done_callback(_PENDING.discard)
return True
# ─── Prewarm (app startup) ──────────────────────────────
# 운영에서 자주 발생하는 쿼리 샘플. 통계 기반으로 확장 예정.
DEFAULT_PREWARM_QUERIES: list[str] = [
# fixed 7 (Phase 2 평가셋 core)
"산업안전보건법 제6장",
"기계 사고 관련 법령",
"AI 산업 동향",
"Python async best practice",
"유해화학물질을 다루는 회사가 지켜야 할 안전 의무",
"recent AI safety news from Europe",
"이세상에 존재하지 않는 문서명",
# 법령 관련
"산업안전보건법 시행령",
"화학물질관리법",
"위험성평가 절차",
# 뉴스 관련
"EU AI Act",
"한국 AI 산업",
# 실무
"안전보건 교육 자료",
"사고 조사 보고서",
"MSDS 작성 방법",
]
async def prewarm_analyzer(
queries: list[str] | None = None,
delay_between: float = 0.2,
) -> dict[str, Any]:
"""app startup에서 호출. 대표 쿼리를 미리 분석해 cache에 적재.
Non-blocking으로 사용: `asyncio.create_task(prewarm_analyzer())`.
Args:
queries: 분석할 쿼리 리스트. None이면 DEFAULT_PREWARM_QUERIES 사용.
delay_between: 각 쿼리 간 대기 시간 (MLX 부하 완화).
Returns:
dict — {queries_total, success, failed, elapsed_ms, cache_size_after}
"""
if not ANALYZE_PROMPT:
logger.warning("prewarm skipped — prompt not loaded")
return {"status": "skipped", "reason": "prompt_not_loaded"}
targets = queries if queries is not None else DEFAULT_PREWARM_QUERIES
total = len(targets)
success = 0
failed = 0
t_start = time.perf_counter()
logger.info("prewarm_analyzer start queries=%d timeout_ms=%d", total, LLM_TIMEOUT_MS)
client = AIClient()
try:
for i, q in enumerate(targets, 1):
if get_cached(q) is not None:
logger.info("prewarm skip (already cached) [%d/%d] %r", i, total, q[:40])
success += 1
continue
result = await analyze(q, ai_client=client)
conf = float(result.get("analyzer_confidence", 0.0) or 0.0)
if conf >= MIN_CACHE_CONFIDENCE:
success += 1
logger.info("prewarm ok [%d/%d] conf=%.2f q=%r", i, total, conf, q[:40])
else:
failed += 1
reason = result.get("_fallback_reason", "low_conf")
logger.warning(
"prewarm fail [%d/%d] reason=%s q=%r", i, total, reason, q[:40]
)
if delay_between > 0 and i < total:
await asyncio.sleep(delay_between)
finally:
try:
await client.close()
except Exception:
pass
elapsed_ms = (time.perf_counter() - t_start) * 1000
stats = cache_stats()
logger.info(
"prewarm_analyzer done total=%d success=%d failed=%d elapsed_ms=%.0f cache_size=%d",
total,
success,
failed,
elapsed_ms,
stats["size"],
)
return {
"status": "complete",
"queries_total": total,
"success": success,
"failed": failed,
"elapsed_ms": elapsed_ms,
"cache_size_after": stats["size"],
}

View File

@@ -1,4 +1,4 @@
"""검색 후보 수집 서비스 (Phase 1.2). """검색 후보 수집 서비스 (Phase 1.2 + Phase 2.2 multilingual).
text(documents FTS + trigram) + vector(documents.embedding + chunks.embedding hybrid) 후보를 text(documents FTS + trigram) + vector(documents.embedding + chunks.embedding hybrid) 후보를
SearchResult 리스트로 반환. SearchResult 리스트로 반환.
@@ -10,27 +10,80 @@ Phase 1.2-G: doc + chunks hybrid retrieval 보강.
- documents.embedding (recall robust, 자연어 매칭 강함) - documents.embedding (recall robust, 자연어 매칭 강함)
- document_chunks.embedding (precision, segment 매칭) - document_chunks.embedding (precision, segment 매칭)
- 두 SQL 동시 호출 후 doc_id 기준 merge (chunk 가중치 1.2, doc 1.0) - 두 SQL 동시 호출 후 doc_id 기준 merge (chunk 가중치 1.2, doc 1.0)
Phase 2.2 추가:
- _QUERY_EMBED_CACHE: bge-m3 query embedding 캐시 (모듈 레벨 LRU, TTL 24h)
- search_vector_multilingual: normalized_queries (lang별 쿼리) 배열 지원
QueryAnalyzer cache hit + analyzer_tier >= merge 일 때만 호출.
- crosslingual_ko_en NDCG 0.53 → 0.65+ 목표
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import TYPE_CHECKING import hashlib
import time
from typing import TYPE_CHECKING, Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ai.client import AIClient from ai.client import AIClient
from core.database import engine from core.database import engine
from core.utils import setup_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from api.search import SearchResult from api.search import SearchResult
logger = setup_logger("retrieval_service")
# Hybrid merge 가중치 (1.2-G) # Hybrid merge 가중치 (1.2-G)
DOC_VECTOR_WEIGHT = 1.0 DOC_VECTOR_WEIGHT = 1.0
CHUNK_VECTOR_WEIGHT = 1.2 CHUNK_VECTOR_WEIGHT = 1.2
# ─── Phase 2.2: Query embedding cache ───────────────────
# bge-m3 호출 비용 절반 감소 (동일 normalized_query 재호출 방지)
_QUERY_EMBED_CACHE: dict[str, dict[str, Any]] = {}
QUERY_EMBED_TTL = 86400 # 24h
QUERY_EMBED_MAXSIZE = 500
def _query_embed_key(text_: str) -> str:
return hashlib.sha256(f"{text_}|bge-m3".encode("utf-8")).hexdigest()
async def _get_query_embedding(
client: AIClient, text_: str
) -> list[float] | None:
"""Query embedding with in-memory cache.
동일 텍스트 재호출 시 bge-m3 skip. fixed query 회귀 시 vector_ms 대폭 감소.
"""
if not text_:
return None
key = _query_embed_key(text_)
entry = _QUERY_EMBED_CACHE.get(key)
if entry and time.time() - entry["ts"] < QUERY_EMBED_TTL:
return entry["emb"]
try:
emb = await client.embed(text_)
except Exception as exc:
logger.warning("query embed failed text=%r err=%r", text_[:40], exc)
return None
if len(_QUERY_EMBED_CACHE) >= QUERY_EMBED_MAXSIZE:
try:
oldest = next(iter(_QUERY_EMBED_CACHE))
_QUERY_EMBED_CACHE.pop(oldest, None)
except StopIteration:
pass
_QUERY_EMBED_CACHE[key] = {"emb": emb, "ts": time.time()}
return emb
def query_embed_cache_stats() -> dict[str, int]:
return {"size": len(_QUERY_EMBED_CACHE), "maxsize": QUERY_EMBED_MAXSIZE}
async def search_text( async def search_text(
session: AsyncSession, query: str, limit: int session: AsyncSession, query: str, limit: int
@@ -153,11 +206,16 @@ async def search_vector(
list[SearchResult] — doc_id 중복 제거됨. compress_chunks_to_docs는 그대로 동작. list[SearchResult] — doc_id 중복 제거됨. compress_chunks_to_docs는 그대로 동작.
chunks_by_doc은 search.py에서 group_by_doc으로 보존. chunks_by_doc은 search.py에서 group_by_doc으로 보존.
""" """
try:
client = AIClient() client = AIClient()
query_embedding = await client.embed(query) try:
query_embedding = await _get_query_embedding(client, query)
finally:
try:
await client.close() await client.close()
except Exception: except Exception:
pass
if query_embedding is None:
return [] return []
embedding_str = str(query_embedding) embedding_str = str(query_embedding)
@@ -307,6 +365,100 @@ def _merge_doc_and_chunk_vectors(
return sorted(by_doc_id.values(), key=lambda r: r.score, reverse=True) return sorted(by_doc_id.values(), key=lambda r: r.score, reverse=True)
async def search_vector_multilingual(
session: AsyncSession,
normalized_queries: list[dict],
limit: int,
) -> list["SearchResult"]:
"""Phase 2.2 — 다국어 normalized_queries 배열로 vector retrieval.
각 language query에 대해 embedding을 병렬 생성(cache hit 활용),
각 embedding에 대해 기존 docs+chunks hybrid 호출,
결과를 weight 기반으로 merge.
⚠️ 호출 조건:
- QueryAnalyzer cache hit 이어야 함 (async-only 룰)
- analyzer_confidence 높고 normalized_queries 존재해야 함
- search.py에서만 호출. retrieval 경로 동기 LLM 호출 금지 룰 준수.
Args:
session: AsyncSession (호출자 관리, 본 함수 내부는 sessionmaker로 별도 연결 사용)
normalized_queries: [{"lang": "ko", "text": "...", "weight": 0.56}, ...]
weight는 _normalize_weights로 이미 합=1.0 정규화된 상태.
limit: 상위 결과 개수
Returns:
list[SearchResult] — doc_id 중복 제거. merged score = sum(per-query score * lang_weight).
"""
if not normalized_queries:
return []
# 1. 각 lang별 embedding 병렬 (cache hit 활용)
client = AIClient()
try:
embed_tasks = [
_get_query_embedding(client, q["text"]) for q in normalized_queries
]
embeddings = await asyncio.gather(*embed_tasks)
finally:
try:
await client.close()
except Exception:
pass
# embedding 실패한 query는 skip (weight 재정규화 없이 조용히 drop)
per_query_plan: list[tuple[dict, str]] = []
for q, emb in zip(normalized_queries, embeddings):
if emb is None:
logger.warning("multilingual embed skipped lang=%s", q.get("lang"))
continue
per_query_plan.append((q, str(emb)))
if not per_query_plan:
return []
# 2. 각 embedding에 대해 doc + chunks 병렬 retrieval
Session = async_sessionmaker(engine)
async def _one_query(q_meta: dict, embedding_str: str) -> list["SearchResult"]:
async def _docs() -> list["SearchResult"]:
async with Session() as s:
return await _search_vector_docs(s, embedding_str, limit * 4)
async def _chunks() -> list["SearchResult"]:
async with Session() as s:
return await _search_vector_chunks(s, embedding_str, limit * 4)
doc_r, chunk_r = await asyncio.gather(_docs(), _chunks())
return _merge_doc_and_chunk_vectors(doc_r, chunk_r)
per_query_results = await asyncio.gather(
*(_one_query(q, emb_str) for q, emb_str in per_query_plan)
)
# 3. weight 기반 merge — doc_id 중복 시 weighted score 합산
merged: dict[int, "SearchResult"] = {}
for (q_meta, _emb_str), results in zip(per_query_plan, per_query_results):
weight = float(q_meta.get("weight", 1.0) or 1.0)
for r in results:
weighted = r.score * weight
prev = merged.get(r.id)
if prev is None:
# 첫 방문: 원본을 shallow copy 대신 직접 wrap
r.score = weighted
r.match_reason = f"ml_{q_meta.get('lang', '?')}"
merged[r.id] = r
else:
# 중복: score 누적, 가장 높은 weight 소스로 match_reason 표시
prev.score += weighted
# match_reason 병합 (가독성)
if q_meta.get("lang") and q_meta.get("lang") not in (prev.match_reason or ""):
prev.match_reason = (prev.match_reason or "ml") + f"+{q_meta['lang']}"
sorted_results = sorted(merged.values(), key=lambda r: r.score, reverse=True)
return sorted_results[: limit * 4] # rerank 후보로 넉넉히
def compress_chunks_to_docs( def compress_chunks_to_docs(
chunks: list["SearchResult"], limit: int chunks: list["SearchResult"], limit: int
) -> tuple[list["SearchResult"], dict[int, list["SearchResult"]]]: ) -> tuple[list["SearchResult"], dict[int, list["SearchResult"]]]:

View File

@@ -244,6 +244,7 @@ async def record_search_event(
results: list[Any], results: list[Any],
mode: str, mode: str,
confidence: float | None = None, confidence: float | None = None,
analyzer_confidence: float | None = None,
) -> None: ) -> None:
"""검색 응답 직후 호출. 실패 트리거에 해당하면 로그 INSERT. """검색 응답 직후 호출. 실패 트리거에 해당하면 로그 INSERT.
@@ -253,6 +254,13 @@ async def record_search_event(
confidence 파라미터: confidence 파라미터:
- None이면 results 기준으로 자체 계산 (legacy 호출용). - None이면 results 기준으로 자체 계산 (legacy 호출용).
- 명시적으로 전달되면 그 값 사용 (Phase 0.5+: fusion 적용 전 raw 신호 기준). - 명시적으로 전달되면 그 값 사용 (Phase 0.5+: fusion 적용 전 raw 신호 기준).
analyzer_confidence (Phase 2.1):
- QueryAnalyzer의 쿼리 분석 신뢰도 (result confidence와 다른 축).
- `result.confidence` 가 낮더라도 `analyzer_confidence` 가 높으면
"retrieval failure" (corpus에 정답 없음)로 해석 가능.
- 반대로 analyzer_confidence < 0.5 이면 "query understanding failure" 해석.
- Phase 2.1에서는 context에만 기록 (failure_reason 분류는 Phase 2.2+에서).
""" """
if user_id is None: if user_id is None:
return return
@@ -260,7 +268,10 @@ async def record_search_event(
if confidence is None: if confidence is None:
confidence = compute_confidence(results, mode) confidence = compute_confidence(results, mode)
result_count = len(results) result_count = len(results)
base_ctx = _build_context(results, mode, extra={"confidence": confidence}) extra_ctx: dict[str, Any] = {"confidence": confidence}
if analyzer_confidence is not None:
extra_ctx["analyzer_confidence"] = float(analyzer_confidence)
base_ctx = _build_context(results, mode, extra=extra_ctx)
# ── 1) reformulation 체크 (이전 쿼리가 있으면 그걸 로깅) ── # ── 1) reformulation 체크 (이전 쿼리가 있으면 그걸 로깅) ──
prior = await _record_and_get_prior(user_id, query) prior = await _record_and_get_prior(user_id, query)

View File

@@ -6,8 +6,8 @@ ai:
models: models:
primary: primary:
endpoint: "http://100.76.254.116:8800/v1/chat/completions" endpoint: "http://100.76.254.116:8801/v1/chat/completions"
model: "mlx-community/Qwen3.5-35B-A3B-4bit" model: "mlx-community/gemma-4-26b-a4b-it-8bit"
max_tokens: 4096 max_tokens: 4096
timeout: 60 timeout: 60

125
reports/phase2_final.md Normal file
View File

@@ -0,0 +1,125 @@
# Phase 2 최종 측정 보고서
**측정일**: 2026-04-08
**대상**: Document Server 검색 v2, Phase 2.1~2.3 통합
**평가셋**: `tests/search_eval/queries.yaml` v0.1 (23 쿼리, 8 카테고리)
**인프라 기준**: `memory/infra_inventory.md` (2026-04-08 실측)
## A/B 결과
| metric | Phase 1.3 baseline (A) | Phase 2 final (B) | Δ |
|---|---|---|---|
| Recall@10 | 0.730 | **0.737** | +0.007 ✓ |
| MRR@10 | 0.795 | 0.797 | +0.002 |
| NDCG@10 | 0.663 | **0.668** | +0.005 ✓ |
| Top-3 hit | 0.900 | 0.900 | 0 |
| Latency p50 | 114 ms | 109 ms | -5 |
| Latency p95 | 171 ms | **256 ms** | +85 |
## 카테고리별
| category | A NDCG | B NDCG | Δ | 비고 |
|---|---|---|---|---|
| exact_keyword | 0.96 | 0.96 | 0 | 회귀 0 ✓ |
| natural_language_ko | 0.73 | 0.73 | 0 | 회귀 0 ✓ (narrowed multilingual 덕) |
| crosslingual_ko_en | 0.53 | 0.53 | 0 | bge-m3 한계 — multilingual 효과 0 |
| **news_crosslingual** | 0.27 | **0.37** | **+0.10** | 개선 ✓ |
| news_ko | 0.36 | 0.37 | +0.01 | 미세 |
| news_en | 0.00 | 0.00 | 0 | 여전히 0 |
| news_fr | 0.46 | 0.46 | 0 | |
| other_domain | 0.88 | 0.88 | 0 | |
## Phase 2 게이트 검증
| 게이트 | 목표 | 실제 | 상태 |
|---|---|---|---|
| Recall@10 | ≥ 0.78 | 0.737 | ❌ (-0.043) |
| Top-3 hit | ≥ 0.93 | 0.900 | ❌ (-0.030) |
| crosslingual_ko_en NDCG | ≥ 0.65 | 0.53 | ❌ (-0.12) |
| news_crosslingual NDCG | ≥ 0.30 | 0.37 | ✓ |
| latency p95 | < 400 ms | 256 ms | ✓ |
| 평가셋 v0.2 완료 | - | v0.1만 | ❌ (후속) |
**2/6 통과** — 목표 미달. 단 회귀 0 + 일부 영역 개선.
## Phase 2에서 실제로 달성한 것
### 1. 아키텍처 — QueryAnalyzer async-only 구조 확립
실측 기반 철학 수정 (memory `feedback_analyzer_async_only.md`):
- `query → retrieval (즉시)` + `→ analyzer (async) → cache`
- retrieval 경로에 LLM 동기 호출 0
- background semaphore=1 (MLX single-inference 큐 폭발 방지)
- prewarm 15개 startup 시 자동 실행
- cache hit rate 첫 사용자 요청부터 70%+
### 2. 실측 데이터 — MLX 한계
gemma-4-26b-a4b-it-8bit MLX:
- full prompt (prompt_tok=2406) → **10.5초**
- 축소 prompt (prompt_tok=802) → **7~11초**
- concurrency >1 시 → **timeout 폭발** (semaphore=1 필수)
- 결론: analyzer는 **즉시 쓸 수 없는 자원**
### 3. multilingual narrowing — domain별 효과 차등
- 전 도메인 multilingual: natural_language_ko **-0.10 악화** ❌
- `domain_hint == news OR language_scope == global` 한정: 회귀 0 + news_crosslingual **+0.10** ✓
- 룰: 한국어 법령 검색에 영어 번역 쿼리 섞으면 noise
### 4. soft_filter boost — 보수적 설정 필요
- 초기 0.03+0.02 → exact_keyword **-0.03 악화**
- 낮춰서 0.01 단일 domain only → 회귀 0
- 평가셋에 filter 쿼리가 없어 효과 직접 측정 불가 (v0.2 확장 후 재평가)
## Phase 2에서 달성하지 못한 것 + 이유
### Recall@10 / Top-3 hit 회복 (0.730 → 0.78+ 미달)
- baseline 대비 +0.007 미세 개선만
- 원인: **corpus 1022 docs로 noise 증가**. chunk 수 7129. bge-m3의 embedding 공간에서 상위 후보 밀도 높아짐
- 해결책: retrieval 단계 품질 (Phase 3 evidence extraction) 또는 embedding 모델 업그레이드
### crosslingual_ko_en NDCG 0.65+ 미달 (0.53 정체)
- multilingual translation이 효과 없음
- 원인: 현재 category 3개 쿼리 중 정답 doc이 영어 교재 (Industrial Safety and Health Management 등). bge-m3는 ko 쿼리로 이 영어 doc을 약 0.5~0.6 cosine으로 이미 찾음. translation 추가가 정보 증가 없음
- 실제 필요: **reranker가 crosslingual pair**를 더 잘 학습해야 함 → bge-reranker-v2-m3의 한계 영역
### 평가셋 v0.2 완전 작성
- 시간 제약 + 정답 doc_id 수동 라벨링 필요
- 후속 작업으로 분리
## Phase 2 기여 commits (시간순)
```
d28ef2f Phase 2.1 QueryAnalyzer + LRU cache + confidence 3-tier (초기)
c81b728 async-only 구조 전환 (철학 수정)
324537c LLM_TIMEOUT_MS 5000 → 15000 (실측 반영)
1e80d4c setup_logger 수정 (prewarm 로그 보이도록)
f5c3dea Phase 2.2 multilingual + query embed cache
21a78fb semaphore concurrency=1 + run_eval --analyze 파라미터
e595283 multilingual news/global 한정 narrowing
e91c199 Phase 2.3 soft_filter boost (초기)
01f144a soft_filter boost 약화 (0.01, doctype 제거)
```
## 다음 단계 선택지 (사용자 결정)
### A. Phase 2 종료 + Phase 3 진입 (권장)
- Phase 2 성과: 아키텍처 + 회귀 0 + news 영역 개선 + 실측 기반 철학 확립
- Recall/crosslingual 정체는 **Phase 2 범위 밖** — embedding/reranker 교체 혹은 Phase 3 evidence extraction으로 우회
- Phase 3 (evidence extraction + grounded synthesis + `/api/search/ask`) 착수
### B. Phase 2 iteration — embedding 실험
- bge-m3 → 다른 embedding (e.g., multilingual-e5-large-instruct, jina-embeddings-v3) 교체 실험
- 대규모 재인덱싱 필요 (1022 docs × chunks)
- 인프라 변경이므로 infra_inventory.md drift 발생
### C. Phase 2 iteration — 평가셋 v0.2 작성
- queries_v0.2.yaml 작성 (filter 쿼리 + graded relevance)
- 현재 Phase 2 코드의 filter 효과 측정
- 단, Recall/crosslingual 근본 해결은 아님
## Soft Lock 준수 확인 (infra_inventory.md)
-`config.yaml` 변경 없음 (GPU local override 그대로)
-`docker compose restart` 사용 안 함 (`up -d --build fastapi`만)
- ✓ Ollama 모델 pull/remove 없음 (bge-m3, exaone3.5 그대로)
- ✓ Reranker 모델 변경 없음 (TEI bge-reranker-v2-m3 그대로)
- ✓ Mac mini MLX 설정 변경 없음

View File

@@ -0,0 +1,24 @@
label,id,category,intent,domain_hint,query,relevant_ids,returned_ids_top10,latency_ms,recall_at_10,mrr_at_10,ndcg_at_10,top3_hit,error
single,kw_001,exact_keyword,fact_lookup,document,산업안전보건법 제6장,3856;3868;3879,3856;3851;3862;3853;3861;3868;3879;3873;3876;3871,95.5,1.000,1.000,0.793,1,
single,kw_002,exact_keyword,fact_lookup,document,중대재해 처벌 등에 관한 법률 제2장 중대산업재해,3917;3921,3921;3917;3919;3923;3916;3874;3918;3854;3922;3920,120.7,1.000,1.000,1.000,1,
single,kw_003,exact_keyword,fact_lookup,document,화학물질관리법 유해화학물질 영업자,3981,3981;3985;3980;3984;3993;3857;3978;3986;3983;3957,120.2,1.000,1.000,1.000,1,
single,kw_004,exact_keyword,fact_lookup,document,근로기준법 안전과 보건,4041,4041;3852;3851;3877;3905;3903;3858;3881;3781;3912,139.3,1.000,1.000,1.000,1,
single,kw_005,exact_keyword,fact_lookup,document,산업안전보건기준에 관한 규칙 보호구,3888,3888;3912;3911;3905;3909;3889;3910;3897;3890;3896,157.8,1.000,1.000,1.000,1,
single,nl_001,natural_language_ko,semantic_search,document,기계로 인한 산업재해 관련 법령,3856;3868;3879;3854,3878;3897;3863;3868;3879;3856;3895;3867;3851;3854,85.9,1.000,0.250,0.571,0,
single,nl_002,natural_language_ko,semantic_search,document,사업주가 도급을 줄 때 산업재해를 예방하기 위해 해야 할 일,3855;3867;3878,3855;3917;3854;3867;3878;3863;3851;3908;3903;3895,82.1,1.000,1.000,0.853,1,
single,nl_003,natural_language_ko,semantic_search,document,유해화학물질을 다루는 회사가 지켜야 할 안전 의무,3980;3981;3982,3980;3903;3904;3905;3981;3985;3896;3917;3857;3909,117.0,0.667,1.000,0.651,1,
single,nl_004,natural_language_ko,semantic_search,document,중대재해가 발생했을 때 경영책임자가 처벌받는 기준,3916;3917;3920;3921,3917;3918;3916;3923;3919;3921;3854;3872;3877;3922,113.6,0.750,1.000,0.725,1,
single,nl_005,natural_language_ko,semantic_search,document,안전보건교육은 누가 받아야 하고 어떤 내용을 다루는가,3853;3865,3853;4025;3876;3879;3859;3865;3781;3815;3818;3787,80.6,1.000,1.000,0.832,1,
single,cl_001,crosslingual_ko_en,semantic_search,document,기계 안전 가드 설계 원리,3770;3856,3770;4540;3817;4541;3774;3816;3787;3758;3793;3773,79.0,0.500,1.000,0.613,1,
single,cl_002,crosslingual_ko_en,semantic_search,document,산업 안전 입문서,3755;3775;3776;3777,3760;3755;3774;3764;3758;3775;3779;3802;3814;3817,107.6,0.500,0.500,0.385,1,
single,cl_003,crosslingual_ko_en,semantic_search,document,전기 안전 위험,3772;3790,3897;3772;3771;4018;3773;3790;3819;4020;3807;3755,125.6,1.000,0.500,0.605,1,
single,news_001,news_ko,semantic_search,news,이란과 미국의 군사 충돌,4303;4304;4307;4316;4322;4323;4327;4335,4317;4321;4771;4446;4743;4452;4307;4418;4331;4744,94.5,0.125,0.143,0.084,1,
single,news_002,news_ko,semantic_search,news,호르무즈 해협 봉쇄,4316;4320;4322;4327,4327;4346;4349;4762;4767;4759;4322;4320;4340;4304,94.3,0.750,1.000,0.644,0,
single,news_003,news_en,semantic_search,news,Trump Iran ultimatum,4258;4260;4262,4776;4515;4519;4658;4644;4763;4333;4762;4679;4321,118.7,0.000,0.000,0.000,1,
single,news_004,news_fr,semantic_search,news,guerre en Iran,4199;4202;4210;4361;4363;4507;4519;4521,4678;4507;4199;4688;4776;4363;4519;4668;4670;4672,119.6,0.500,0.500,0.460,1,
single,news_005,news_crosslingual,semantic_search,news,이란 미국 전쟁 글로벌 반응,4202;4258;4262;4536;4303;4304;4316,4262;4457;4765;4324;4345;4329;4452;4443;4761;4642,95.6,0.143,1.000,0.275,1,
single,misc_001,other_domain,fact_lookup,document,강체의 평면 운동학,4063;4065,4063;4065;4064;4067;4071;4068;4069;4062;4060;4066,172.3,1.000,1.000,1.000,1,
single,misc_002,other_domain,semantic_search,document,질점의 운동역학,4060;4061;4062,4062;4060;4070;4064;4068;4067;4065;4058;4071;4066,261.8,0.667,1.000,0.765,1,
single,fail_001,failure_expected,semantic_search,document,Rust async runtime tokio scheduler 내부 구조,,4815;4069;4546;4062;4547;3801;3787;3812;4542;3770,118.6,0.000,0.000,0.000,1,
single,fail_002,failure_expected,semantic_search,document,양자컴퓨터 큐비트 디코히어런스,,4058;4057;4067;3800;4065;4068;3817;4063;4064;3915,76.9,0.000,0.000,0.000,1,
single,fail_003,failure_expected,semantic_search,news,재즈 보컬리스트 빌리 홀리데이,,4634;4100;4815;4116;4281;4697;4205;4077;4235;4758,73.6,0.000,0.000,0.000,1,
1 label id category intent domain_hint query relevant_ids returned_ids_top10 latency_ms recall_at_10 mrr_at_10 ndcg_at_10 top3_hit error
2 single kw_001 exact_keyword fact_lookup document 산업안전보건법 제6장 3856;3868;3879 3856;3851;3862;3853;3861;3868;3879;3873;3876;3871 95.5 1.000 1.000 0.793 1
3 single kw_002 exact_keyword fact_lookup document 중대재해 처벌 등에 관한 법률 제2장 중대산업재해 3917;3921 3921;3917;3919;3923;3916;3874;3918;3854;3922;3920 120.7 1.000 1.000 1.000 1
4 single kw_003 exact_keyword fact_lookup document 화학물질관리법 유해화학물질 영업자 3981 3981;3985;3980;3984;3993;3857;3978;3986;3983;3957 120.2 1.000 1.000 1.000 1
5 single kw_004 exact_keyword fact_lookup document 근로기준법 안전과 보건 4041 4041;3852;3851;3877;3905;3903;3858;3881;3781;3912 139.3 1.000 1.000 1.000 1
6 single kw_005 exact_keyword fact_lookup document 산업안전보건기준에 관한 규칙 보호구 3888 3888;3912;3911;3905;3909;3889;3910;3897;3890;3896 157.8 1.000 1.000 1.000 1
7 single nl_001 natural_language_ko semantic_search document 기계로 인한 산업재해 관련 법령 3856;3868;3879;3854 3878;3897;3863;3868;3879;3856;3895;3867;3851;3854 85.9 1.000 0.250 0.571 0
8 single nl_002 natural_language_ko semantic_search document 사업주가 도급을 줄 때 산업재해를 예방하기 위해 해야 할 일 3855;3867;3878 3855;3917;3854;3867;3878;3863;3851;3908;3903;3895 82.1 1.000 1.000 0.853 1
9 single nl_003 natural_language_ko semantic_search document 유해화학물질을 다루는 회사가 지켜야 할 안전 의무 3980;3981;3982 3980;3903;3904;3905;3981;3985;3896;3917;3857;3909 117.0 0.667 1.000 0.651 1
10 single nl_004 natural_language_ko semantic_search document 중대재해가 발생했을 때 경영책임자가 처벌받는 기준 3916;3917;3920;3921 3917;3918;3916;3923;3919;3921;3854;3872;3877;3922 113.6 0.750 1.000 0.725 1
11 single nl_005 natural_language_ko semantic_search document 안전보건교육은 누가 받아야 하고 어떤 내용을 다루는가 3853;3865 3853;4025;3876;3879;3859;3865;3781;3815;3818;3787 80.6 1.000 1.000 0.832 1
12 single cl_001 crosslingual_ko_en semantic_search document 기계 안전 가드 설계 원리 3770;3856 3770;4540;3817;4541;3774;3816;3787;3758;3793;3773 79.0 0.500 1.000 0.613 1
13 single cl_002 crosslingual_ko_en semantic_search document 산업 안전 입문서 3755;3775;3776;3777 3760;3755;3774;3764;3758;3775;3779;3802;3814;3817 107.6 0.500 0.500 0.385 1
14 single cl_003 crosslingual_ko_en semantic_search document 전기 안전 위험 3772;3790 3897;3772;3771;4018;3773;3790;3819;4020;3807;3755 125.6 1.000 0.500 0.605 1
15 single news_001 news_ko semantic_search news 이란과 미국의 군사 충돌 4303;4304;4307;4316;4322;4323;4327;4335 4317;4321;4771;4446;4743;4452;4307;4418;4331;4744 94.5 0.125 0.143 0.084 1
16 single news_002 news_ko semantic_search news 호르무즈 해협 봉쇄 4316;4320;4322;4327 4327;4346;4349;4762;4767;4759;4322;4320;4340;4304 94.3 0.750 1.000 0.644 0
17 single news_003 news_en semantic_search news Trump Iran ultimatum 4258;4260;4262 4776;4515;4519;4658;4644;4763;4333;4762;4679;4321 118.7 0.000 0.000 0.000 1
18 single news_004 news_fr semantic_search news guerre en Iran 4199;4202;4210;4361;4363;4507;4519;4521 4678;4507;4199;4688;4776;4363;4519;4668;4670;4672 119.6 0.500 0.500 0.460 1
19 single news_005 news_crosslingual semantic_search news 이란 미국 전쟁 글로벌 반응 4202;4258;4262;4536;4303;4304;4316 4262;4457;4765;4324;4345;4329;4452;4443;4761;4642 95.6 0.143 1.000 0.275 1
20 single misc_001 other_domain fact_lookup document 강체의 평면 운동학 4063;4065 4063;4065;4064;4067;4071;4068;4069;4062;4060;4066 172.3 1.000 1.000 1.000 1
21 single misc_002 other_domain semantic_search document 질점의 운동역학 4060;4061;4062 4062;4060;4070;4064;4068;4067;4065;4058;4071;4066 261.8 0.667 1.000 0.765 1
22 single fail_001 failure_expected semantic_search document Rust async runtime tokio scheduler 내부 구조 4815;4069;4546;4062;4547;3801;3787;3812;4542;3770 118.6 0.000 0.000 0.000 1
23 single fail_002 failure_expected semantic_search document 양자컴퓨터 큐비트 디코히어런스 4058;4057;4067;3800;4065;4068;3817;4063;4064;3915 76.9 0.000 0.000 0.000 1
24 single fail_003 failure_expected semantic_search news 재즈 보컬리스트 빌리 홀리데이 4634;4100;4815;4116;4281;4697;4205;4077;4235;4758 73.6 0.000 0.000 0.000 1

View File

@@ -0,0 +1,24 @@
label,id,category,intent,domain_hint,query,relevant_ids,returned_ids_top10,latency_ms,recall_at_10,mrr_at_10,ndcg_at_10,top3_hit,error
single,kw_001,exact_keyword,fact_lookup,document,산업안전보건법 제6장,3856;3868;3879,3856;3851;3862;3853;3861;3868;3879;3873;3876;3871,67.9,1.000,1.000,0.793,1,
single,kw_002,exact_keyword,fact_lookup,document,중대재해 처벌 등에 관한 법률 제2장 중대산업재해,3917;3921,3921;3917;3919;3923;3916;3874;3918;3854;3922;3920,110.0,1.000,1.000,1.000,1,
single,kw_003,exact_keyword,fact_lookup,document,화학물질관리법 유해화학물질 영업자,3981,3981;3985;3980;3984;3993;3857;3978;3986;3983;3957,119.3,1.000,1.000,1.000,1,
single,kw_004,exact_keyword,fact_lookup,document,근로기준법 안전과 보건,4041,4041;3852;3851;3877;3905;3903;3858;3881;3781;3912,108.7,1.000,1.000,1.000,1,
single,kw_005,exact_keyword,fact_lookup,document,산업안전보건기준에 관한 규칙 보호구,3888,3888;3912;3911;3905;3909;3889;3910;3897;3890;3896,125.5,1.000,1.000,1.000,1,
single,nl_001,natural_language_ko,semantic_search,document,기계로 인한 산업재해 관련 법령,3856;3868;3879;3854,3878;3897;3863;3868;3856;3879;3895;3867;3851;3854,83.9,1.000,0.250,0.571,0,
single,nl_002,natural_language_ko,semantic_search,document,사업주가 도급을 줄 때 산업재해를 예방하기 위해 해야 할 일,3855;3867;3878,3855;3917;3854;3867;3878;3863;3851;3908;3903;3895,118.0,1.000,1.000,0.853,1,
single,nl_003,natural_language_ko,semantic_search,document,유해화학물질을 다루는 회사가 지켜야 할 안전 의무,3980;3981;3982,3980;3903;3904;3905;3981;3985;3896;3917;3857;3909,82.8,0.667,1.000,0.651,1,
single,nl_004,natural_language_ko,semantic_search,document,중대재해가 발생했을 때 경영책임자가 처벌받는 기준,3916;3917;3920;3921,3917;3918;3916;3923;3919;3921;3854;3872;3877;3922,72.1,0.750,1.000,0.725,1,
single,nl_005,natural_language_ko,semantic_search,document,안전보건교육은 누가 받아야 하고 어떤 내용을 다루는가,3853;3865,3853;4025;3876;3879;3859;3865;3781;3815;3818;3787,80.2,1.000,1.000,0.832,1,
single,cl_001,crosslingual_ko_en,semantic_search,document,기계 안전 가드 설계 원리,3770;3856,3770;4540;3817;4541;3774;3816;3787;3758;3793;3773,108.3,0.500,1.000,0.613,1,
single,cl_002,crosslingual_ko_en,semantic_search,document,산업 안전 입문서,3755;3775;3776;3777,3760;3755;3774;3764;3758;3775;3779;3802;3814;3817,79.5,0.500,0.500,0.385,1,
single,cl_003,crosslingual_ko_en,semantic_search,document,전기 안전 위험,3772;3790,3897;3772;3771;4018;3773;3790;3819;4020;3807;3755,103.7,1.000,0.500,0.605,1,
single,news_001,news_ko,semantic_search,news,이란과 미국의 군사 충돌,4303;4304;4307;4316;4322;4323;4327;4335,4317;4321;4771;4743;4307;4452;4761;4678;4418;4331,1445.8,0.125,0.200,0.098,1,
single,news_002,news_ko,semantic_search,news,호르무즈 해협 봉쇄,4316;4320;4322;4327,4327;4346;4349;4762;4767;4759;4322;4320;4340;4304,185.3,0.750,1.000,0.644,0,
single,news_003,news_en,semantic_search,news,Trump Iran ultimatum,4258;4260;4262,4776;4515;4519;4658;4644;4763;4333;4762;4679;4321,76.3,0.000,0.000,0.000,1,
single,news_004,news_fr,semantic_search,news,guerre en Iran,4199;4202;4210;4361;4363;4507;4519;4521,4678;4507;4199;4688;4776;4363;4519;4668;4670;4672,157.9,0.500,0.500,0.460,1,
single,news_005,news_crosslingual,semantic_search,news,이란 미국 전쟁 글로벌 반응,4202;4258;4262;4536;4303;4304;4316,4262;4457;4765;4324;4345;4329;4258;4452;4443;4761,186.7,0.286,1.000,0.367,1,
single,misc_001,other_domain,fact_lookup,document,강체의 평면 운동학,4063;4065,4063;4065;4064;4067;4071;4068;4069;4062;4060;4066,171.6,1.000,1.000,1.000,1,
single,misc_002,other_domain,semantic_search,document,질점의 운동역학,4060;4061;4062,4062;4060;4070;4064;4068;4067;4065;4058;4071;4066,263.6,0.667,1.000,0.765,1,
single,fail_001,failure_expected,semantic_search,document,Rust async runtime tokio scheduler 내부 구조,,4815;4069;4546;4062;4547;3801;3787;3812;4542;3770,121.9,0.000,0.000,0.000,1,
single,fail_002,failure_expected,semantic_search,document,양자컴퓨터 큐비트 디코히어런스,,4058;4057;4067;3800;4065;4068;3817;4063;4064;3915,75.2,0.000,0.000,0.000,1,
single,fail_003,failure_expected,semantic_search,news,재즈 보컬리스트 빌리 홀리데이,,4634;4100;4815;4116;4281;4697;4205;4077;4235;4289,73.9,0.000,0.000,0.000,1,
1 label id category intent domain_hint query relevant_ids returned_ids_top10 latency_ms recall_at_10 mrr_at_10 ndcg_at_10 top3_hit error
2 single kw_001 exact_keyword fact_lookup document 산업안전보건법 제6장 3856;3868;3879 3856;3851;3862;3853;3861;3868;3879;3873;3876;3871 67.9 1.000 1.000 0.793 1
3 single kw_002 exact_keyword fact_lookup document 중대재해 처벌 등에 관한 법률 제2장 중대산업재해 3917;3921 3921;3917;3919;3923;3916;3874;3918;3854;3922;3920 110.0 1.000 1.000 1.000 1
4 single kw_003 exact_keyword fact_lookup document 화학물질관리법 유해화학물질 영업자 3981 3981;3985;3980;3984;3993;3857;3978;3986;3983;3957 119.3 1.000 1.000 1.000 1
5 single kw_004 exact_keyword fact_lookup document 근로기준법 안전과 보건 4041 4041;3852;3851;3877;3905;3903;3858;3881;3781;3912 108.7 1.000 1.000 1.000 1
6 single kw_005 exact_keyword fact_lookup document 산업안전보건기준에 관한 규칙 보호구 3888 3888;3912;3911;3905;3909;3889;3910;3897;3890;3896 125.5 1.000 1.000 1.000 1
7 single nl_001 natural_language_ko semantic_search document 기계로 인한 산업재해 관련 법령 3856;3868;3879;3854 3878;3897;3863;3868;3856;3879;3895;3867;3851;3854 83.9 1.000 0.250 0.571 0
8 single nl_002 natural_language_ko semantic_search document 사업주가 도급을 줄 때 산업재해를 예방하기 위해 해야 할 일 3855;3867;3878 3855;3917;3854;3867;3878;3863;3851;3908;3903;3895 118.0 1.000 1.000 0.853 1
9 single nl_003 natural_language_ko semantic_search document 유해화학물질을 다루는 회사가 지켜야 할 안전 의무 3980;3981;3982 3980;3903;3904;3905;3981;3985;3896;3917;3857;3909 82.8 0.667 1.000 0.651 1
10 single nl_004 natural_language_ko semantic_search document 중대재해가 발생했을 때 경영책임자가 처벌받는 기준 3916;3917;3920;3921 3917;3918;3916;3923;3919;3921;3854;3872;3877;3922 72.1 0.750 1.000 0.725 1
11 single nl_005 natural_language_ko semantic_search document 안전보건교육은 누가 받아야 하고 어떤 내용을 다루는가 3853;3865 3853;4025;3876;3879;3859;3865;3781;3815;3818;3787 80.2 1.000 1.000 0.832 1
12 single cl_001 crosslingual_ko_en semantic_search document 기계 안전 가드 설계 원리 3770;3856 3770;4540;3817;4541;3774;3816;3787;3758;3793;3773 108.3 0.500 1.000 0.613 1
13 single cl_002 crosslingual_ko_en semantic_search document 산업 안전 입문서 3755;3775;3776;3777 3760;3755;3774;3764;3758;3775;3779;3802;3814;3817 79.5 0.500 0.500 0.385 1
14 single cl_003 crosslingual_ko_en semantic_search document 전기 안전 위험 3772;3790 3897;3772;3771;4018;3773;3790;3819;4020;3807;3755 103.7 1.000 0.500 0.605 1
15 single news_001 news_ko semantic_search news 이란과 미국의 군사 충돌 4303;4304;4307;4316;4322;4323;4327;4335 4317;4321;4771;4743;4307;4452;4761;4678;4418;4331 1445.8 0.125 0.200 0.098 1
16 single news_002 news_ko semantic_search news 호르무즈 해협 봉쇄 4316;4320;4322;4327 4327;4346;4349;4762;4767;4759;4322;4320;4340;4304 185.3 0.750 1.000 0.644 0
17 single news_003 news_en semantic_search news Trump Iran ultimatum 4258;4260;4262 4776;4515;4519;4658;4644;4763;4333;4762;4679;4321 76.3 0.000 0.000 0.000 1
18 single news_004 news_fr semantic_search news guerre en Iran 4199;4202;4210;4361;4363;4507;4519;4521 4678;4507;4199;4688;4776;4363;4519;4668;4670;4672 157.9 0.500 0.500 0.460 1
19 single news_005 news_crosslingual semantic_search news 이란 미국 전쟁 글로벌 반응 4202;4258;4262;4536;4303;4304;4316 4262;4457;4765;4324;4345;4329;4258;4452;4443;4761 186.7 0.286 1.000 0.367 1
20 single misc_001 other_domain fact_lookup document 강체의 평면 운동학 4063;4065 4063;4065;4064;4067;4071;4068;4069;4062;4060;4066 171.6 1.000 1.000 1.000 1
21 single misc_002 other_domain semantic_search document 질점의 운동역학 4060;4061;4062 4062;4060;4070;4064;4068;4067;4065;4058;4071;4066 263.6 0.667 1.000 0.765 1
22 single fail_001 failure_expected semantic_search document Rust async runtime tokio scheduler 내부 구조 4815;4069;4546;4062;4547;3801;3787;3812;4542;3770 121.9 0.000 0.000 0.000 1
23 single fail_002 failure_expected semantic_search document 양자컴퓨터 큐비트 디코히어런스 4058;4057;4067;3800;4065;4068;3817;4063;4064;3915 75.2 0.000 0.000 0.000 1
24 single fail_003 failure_expected semantic_search news 재즈 보컬리스트 빌리 홀리데이 4634;4100;4815;4116;4281;4697;4205;4077;4235;4289 73.9 0.000 0.000 0.000 1

View File

@@ -134,6 +134,7 @@ async def call_search(
limit: int = 20, limit: int = 20,
fusion: str | None = None, fusion: str | None = None,
rerank: str | None = None, rerank: str | None = None,
analyze: str | None = None,
) -> tuple[list[int], float]: ) -> tuple[list[int], float]:
"""검색 API 호출 → (doc_ids, latency_ms).""" """검색 API 호출 → (doc_ids, latency_ms)."""
url = f"{base_url.rstrip('/')}/api/search/" url = f"{base_url.rstrip('/')}/api/search/"
@@ -143,6 +144,8 @@ async def call_search(
params["fusion"] = fusion params["fusion"] = fusion
if rerank is not None: if rerank is not None:
params["rerank"] = rerank params["rerank"] = rerank
if analyze is not None:
params["analyze"] = analyze
import time import time
@@ -169,6 +172,7 @@ async def evaluate(
mode: str = "hybrid", mode: str = "hybrid",
fusion: str | None = None, fusion: str | None = None,
rerank: str | None = None, rerank: str | None = None,
analyze: str | None = None,
) -> list[QueryResult]: ) -> list[QueryResult]:
"""전체 쿼리셋 평가.""" """전체 쿼리셋 평가."""
results: list[QueryResult] = [] results: list[QueryResult] = []
@@ -177,7 +181,7 @@ async def evaluate(
for q in queries: for q in queries:
try: try:
returned_ids, latency_ms = await call_search( returned_ids, latency_ms = await call_search(
client, base_url, token, q.query, mode=mode, fusion=fusion, rerank=rerank client, base_url, token, q.query, mode=mode, fusion=fusion, rerank=rerank, analyze=analyze
) )
results.append( results.append(
QueryResult( QueryResult(
@@ -415,6 +419,13 @@ def main() -> int:
choices=["true", "false"], choices=["true", "false"],
help="bge-reranker-v2-m3 활성화 (Phase 1.3+, 미지정 시 서버 기본값=true)", help="bge-reranker-v2-m3 활성화 (Phase 1.3+, 미지정 시 서버 기본값=true)",
) )
parser.add_argument(
"--analyze",
type=str,
default=None,
choices=["true", "false"],
help="QueryAnalyzer 활성화 (Phase 2.1+, cache hit 시 multilingual 적용)",
)
parser.add_argument( parser.add_argument(
"--token", "--token",
type=str, type=str,
@@ -454,21 +465,21 @@ def main() -> int:
if args.base_url: if args.base_url:
print(f"\n>>> evaluating: {args.base_url}") print(f"\n>>> evaluating: {args.base_url}")
results = asyncio.run( results = asyncio.run(
evaluate(queries, args.base_url, args.token, "single", mode=args.mode, fusion=args.fusion, rerank=args.rerank) evaluate(queries, args.base_url, args.token, "single", mode=args.mode, fusion=args.fusion, rerank=args.rerank, analyze=args.analyze)
) )
print_summary("single", results) print_summary("single", results)
all_results.extend(results) all_results.extend(results)
else: else:
print(f"\n>>> baseline: {args.baseline_url}") print(f"\n>>> baseline: {args.baseline_url}")
baseline_results = asyncio.run( baseline_results = asyncio.run(
evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode, fusion=args.fusion, rerank=args.rerank) evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode, fusion=args.fusion, rerank=args.rerank, analyze=args.analyze)
) )
baseline_summary = print_summary("baseline", baseline_results) baseline_summary = print_summary("baseline", baseline_results)
print(f"\n>>> candidate: {args.candidate_url}") print(f"\n>>> candidate: {args.candidate_url}")
candidate_results = asyncio.run( candidate_results = asyncio.run(
evaluate( evaluate(
queries, args.candidate_url, args.token, "candidate", mode=args.mode, fusion=args.fusion, rerank=args.rerank queries, args.candidate_url, args.token, "candidate", mode=args.mode, fusion=args.fusion, rerank=args.rerank, analyze=args.analyze
) )
) )
candidate_summary = print_summary("candidate", candidate_results) candidate_summary = print_summary("candidate", candidate_results)