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:
@@ -13,7 +13,12 @@ from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from core.utils import setup_logger
|
||||
from models.user import User
|
||||
from services.search_telemetry import compute_confidence, record_search_event
|
||||
from services.search_fusion import DEFAULT_FUSION, get_strategy, normalize_display_scores
|
||||
from services.search_telemetry import (
|
||||
compute_confidence,
|
||||
compute_confidence_hybrid,
|
||||
record_search_event,
|
||||
)
|
||||
|
||||
# logs/search.log + stdout 동시 출력 (Phase 0.4)
|
||||
logger = setup_logger("search")
|
||||
@@ -80,9 +85,14 @@ async def search(
|
||||
background_tasks: BackgroundTasks,
|
||||
mode: str = Query("hybrid", pattern="^(fts|trgm|vector|hybrid)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
fusion: str = Query(
|
||||
DEFAULT_FUSION,
|
||||
pattern="^(legacy|rrf|rrf_boost)$",
|
||||
description="hybrid 모드 fusion 전략 (legacy=기존 가중합, rrf=RRF k=60, rrf_boost=RRF+강한신호 boost)",
|
||||
),
|
||||
debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합"""
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
|
||||
timing: dict[str, float] = {}
|
||||
notes: list[str] = []
|
||||
text_results: list[SearchResult] = []
|
||||
@@ -110,22 +120,39 @@ async def search(
|
||||
notes.append("vector_search_returned_empty — text-only fallback")
|
||||
|
||||
t2 = time.perf_counter()
|
||||
results = _merge_results(text_results, vector_results, limit)
|
||||
timing["merge_ms"] = (time.perf_counter() - t2) * 1000
|
||||
strategy = get_strategy(fusion)
|
||||
results = strategy.fuse(text_results, vector_results, q, limit)
|
||||
timing["fusion_ms"] = (time.perf_counter() - t2) * 1000
|
||||
notes.append(f"fusion={strategy.name}")
|
||||
else:
|
||||
results = text_results
|
||||
|
||||
# display score 정규화 — 프론트엔드는 score*100을 % 표시.
|
||||
# fusion 내부 score(RRF는 0.01~0.05 범위)를 그대로 노출하면 표시가 깨짐.
|
||||
normalize_display_scores(results)
|
||||
|
||||
timing["total_ms"] = (time.perf_counter() - t_total) * 1000
|
||||
|
||||
# confidence는 fusion 적용 전 raw 신호로 계산 (Phase 0.5 이후 fused score는 절대값 의미 없음)
|
||||
if mode == "hybrid":
|
||||
confidence_signal = compute_confidence_hybrid(text_results, vector_results)
|
||||
elif mode == "vector":
|
||||
confidence_signal = compute_confidence(vector_results, "vector")
|
||||
else:
|
||||
confidence_signal = compute_confidence(text_results, mode)
|
||||
|
||||
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
|
||||
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
|
||||
fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
|
||||
logger.info(
|
||||
"search query=%r mode=%s results=%d %s",
|
||||
q[:80], mode, len(results), timing_str,
|
||||
"search query=%r mode=%s%s results=%d conf=%.2f %s",
|
||||
q[:80], mode, fusion_str, len(results), confidence_signal, timing_str,
|
||||
)
|
||||
|
||||
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
|
||||
background_tasks.add_task(record_search_event, q, user.id, results, mode)
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, results, mode, confidence_signal
|
||||
)
|
||||
|
||||
debug_obj: SearchDebug | None = None
|
||||
if debug:
|
||||
@@ -134,7 +161,7 @@ async def search(
|
||||
text_candidates=_to_debug_candidates(text_results) if text_results or mode != "vector" else None,
|
||||
vector_candidates=_to_debug_candidates(vector_results) if vector_results or mode in ("vector", "hybrid") else None,
|
||||
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
|
||||
confidence=compute_confidence(results, mode),
|
||||
confidence=confidence_signal,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@@ -221,33 +248,3 @@ async def _search_vector(session: AsyncSession, query: str, limit: int) -> list[
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
|
||||
def _merge_results(
|
||||
text_results: list[SearchResult],
|
||||
vector_results: list[SearchResult],
|
||||
limit: int,
|
||||
) -> list[SearchResult]:
|
||||
"""텍스트 + 벡터 결과 합산 (중복 제거, 점수 합산)"""
|
||||
merged: dict[int, SearchResult] = {}
|
||||
|
||||
for r in text_results:
|
||||
merged[r.id] = r
|
||||
|
||||
for r in vector_results:
|
||||
if r.id in merged:
|
||||
# 이미 텍스트로 잡힌 문서 — 벡터 점수 가산
|
||||
existing = merged[r.id]
|
||||
merged[r.id] = SearchResult(
|
||||
id=existing.id,
|
||||
title=existing.title,
|
||||
ai_domain=existing.ai_domain,
|
||||
ai_summary=existing.ai_summary,
|
||||
file_format=existing.file_format,
|
||||
score=existing.score + r.score * 0.5,
|
||||
snippet=existing.snippet,
|
||||
match_reason=f"{existing.match_reason}+vector",
|
||||
)
|
||||
elif r.score > 0.3: # 벡터 유사도 최소 threshold
|
||||
merged[r.id] = r
|
||||
|
||||
results = sorted(merged.values(), key=lambda x: x.score, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
Reference in New Issue
Block a user