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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user