Compare commits
11 Commits
feature/de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
120db86d74 | ||
|
|
01f144ab25 | ||
|
|
e91c199537 | ||
|
|
e595283e27 | ||
|
|
21a78fbbf0 | ||
|
|
f5c3dea833 | ||
|
|
1e80d4c613 | ||
|
|
324537cbc8 | ||
|
|
c81b728ddf | ||
|
|
d28ef2fca0 | ||
|
|
de08735420 |
15
CLAUDE.md
15
CLAUDE.md
@@ -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) 웹 애플리케이션.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
12
app/main.py
12
app/main.py
@@ -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 순서로 정리
|
||||||
|
|||||||
53
app/prompts/query_analyze.txt
Normal file
53
app/prompts/query_analyze.txt
Normal 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}
|
||||||
@@ -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 정규화 ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"]]]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
125
reports/phase2_final.md
Normal 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 설정 변경 없음
|
||||||
24
reports/phase2_final_baseline.csv
Normal file
24
reports/phase2_final_baseline.csv
Normal 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,
|
||||||
|
24
reports/phase2_final_candidate.csv
Normal file
24
reports/phase2_final_candidate.csv
Normal 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,
|
||||||
|
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user