feat(search): Phase 0.5 RRF fusion + 강한 신호 boost

기존 weighted-sum merge를 Reciprocal Rank Fusion으로 교체.
정확 키워드 매치에서 RRF가 평탄화되는 문제는 boost로 보완.

신규 모듈 app/services/search_fusion.py:
- FusionStrategy ABC
- LegacyWeightedSum  : 기존 _merge_results 동작 (A/B 비교용)
- RRFOnly            : 순수 RRF, k=60
- RRFWithBoost       : RRF + title/tags/법령조문/high-text-score boost (default)
- normalize_display_scores: SearchResult.score를 [0..1] 랭크 기반 정규화
  (프론트엔드가 score*100을 % 표시하므로 RRF 원본 점수 노출 시 표시 깨짐)

search.py:
- ?fusion=legacy|rrf|rrf_boost 파라미터 (default rrf_boost)
- _merge_results 제거 (LegacyWeightedSum에 흡수)
- pre-fusion confidence: hybrid는 raw text/vector 신호로 계산
  (fused score는 fusion 전략마다 스케일이 달라 일관 비교 불가)
- timing에 fusion_ms 추가
- debug notes에 fusion 전략 표시

telemetry:
- compute_confidence_hybrid(text_results, vector_results) 헬퍼
- record_search_event에 confidence override 파라미터

run_eval.py:
- --fusion CLI 옵션, call_search 쿼리 파라미터에 전달

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-07 08:58:33 +09:00
parent 1af94d1004
commit 161ff18a31
6 changed files with 778 additions and 45 deletions

View File

@@ -132,11 +132,14 @@ async def call_search(
query: str,
mode: str = "hybrid",
limit: int = 20,
fusion: str | None = None,
) -> tuple[list[int], float]:
"""검색 API 호출 → (doc_ids, latency_ms)."""
url = f"{base_url.rstrip('/')}/api/search/"
headers = {"Authorization": f"Bearer {token}"}
params = {"q": query, "mode": mode, "limit": limit}
params: dict[str, str | int] = {"q": query, "mode": mode, "limit": limit}
if fusion:
params["fusion"] = fusion
import time
@@ -161,6 +164,7 @@ async def evaluate(
token: str,
label: str,
mode: str = "hybrid",
fusion: str | None = None,
) -> list[QueryResult]:
"""전체 쿼리셋 평가."""
results: list[QueryResult] = []
@@ -169,7 +173,7 @@ async def evaluate(
for q in queries:
try:
returned_ids, latency_ms = await call_search(
client, base_url, token, q.query, mode=mode
client, base_url, token, q.query, mode=mode, fusion=fusion
)
results.append(
QueryResult(
@@ -393,6 +397,13 @@ def main() -> int:
choices=["fts", "trgm", "vector", "hybrid"],
help="검색 mode 파라미터",
)
parser.add_argument(
"--fusion",
type=str,
default=None,
choices=["legacy", "rrf", "rrf_boost"],
help="hybrid 모드 fusion 전략 (Phase 0.5+, 미지정 시 서버 기본값)",
)
parser.add_argument(
"--token",
type=str,
@@ -420,28 +431,31 @@ def main() -> int:
queries = load_queries(args.queries)
print(f"Loaded {len(queries)} queries from {args.queries}")
print(f"Mode: {args.mode}")
print(f"Mode: {args.mode}", end="")
if args.fusion:
print(f" / fusion: {args.fusion}", end="")
print()
all_results: list[QueryResult] = []
if args.base_url:
print(f"\n>>> evaluating: {args.base_url}")
results = asyncio.run(
evaluate(queries, args.base_url, args.token, "single", mode=args.mode)
evaluate(queries, args.base_url, args.token, "single", mode=args.mode, fusion=args.fusion)
)
print_summary("single", results)
all_results.extend(results)
else:
print(f"\n>>> baseline: {args.baseline_url}")
baseline_results = asyncio.run(
evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode)
evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode, fusion=args.fusion)
)
baseline_summary = print_summary("baseline", baseline_results)
print(f"\n>>> candidate: {args.candidate_url}")
candidate_results = asyncio.run(
evaluate(
queries, args.candidate_url, args.token, "candidate", mode=args.mode
queries, args.candidate_url, args.token, "candidate", mode=args.mode, fusion=args.fusion
)
)
candidate_summary = print_summary("candidate", candidate_results)