diff --git a/app/api/search.py b/app/api/search.py
index 1b2020e..caf7ce8 100644
--- a/app/api/search.py
+++ b/app/api/search.py
@@ -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]
diff --git a/app/services/search_fusion.py b/app/services/search_fusion.py
new file mode 100644
index 0000000..77de3ac
--- /dev/null
+++ b/app/services/search_fusion.py
@@ -0,0 +1,239 @@
+"""검색 결과 fusion 전략 (Phase 0.5)
+
+기존 가중합 → Reciprocal Rank Fusion 기본 + 강한 시그널 boost.
+
+전략 비교:
+- LegacyWeightedSum : 기존 _merge_results (text 가중치 + 0.5*벡터 합산). A/B 비교용.
+- RRFOnly : 순수 RRF, k=60. 안정적이지만 강한 키워드 신호 약화 가능.
+- RRFWithBoost : RRF + 강한 시그널 boost (title/tags/법령조문/high text score).
+ 정확 키워드 케이스에서 RRF 한계를 보완. **default**.
+
+fuse() 결과의 .score는 fusion 내부 점수(RRF는 1/60 단위로 작음).
+사용자에게 노출되는 SearchResult.score는 search.py에서 normalize_display_scores로
+[0..1] 랭크 기반 정규화 후 반환된다.
+"""
+
+from __future__ import annotations
+
+import re
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from api.search import SearchResult
+
+
+# ─── 추상 인터페이스 ─────────────────────────────────────
+
+
+class FusionStrategy(ABC):
+ name: str = "abstract"
+
+ @abstractmethod
+ def fuse(
+ self,
+ text_results: list["SearchResult"],
+ vector_results: list["SearchResult"],
+ query: str,
+ limit: int,
+ ) -> list["SearchResult"]:
+ ...
+
+
+# ─── 1) 기존 가중합 (legacy) ─────────────────────────────
+
+
+class LegacyWeightedSum(FusionStrategy):
+ """기존 _merge_results 동작.
+
+ 텍스트 점수에 벡터 cosine * 0.5 가산. 벡터 단독 결과는 cosine > 0.3만 채택.
+ Phase 0.5 RRF로 교체 전 baseline. A/B 비교용으로 보존.
+ """
+
+ name = "legacy"
+
+ def fuse(self, text_results, vector_results, query, limit):
+ from api.search import SearchResult # 순환 import 회피
+
+ 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:
+ merged[r.id] = r
+
+ ordered = sorted(merged.values(), key=lambda x: x.score, reverse=True)
+ return ordered[:limit]
+
+
+# ─── 2) Reciprocal Rank Fusion ──────────────────────────
+
+
+class RRFOnly(FusionStrategy):
+ """순수 RRF.
+
+ RRF_score(doc) = Σ (1 / (k + rank_i))
+ k=60 (TREC 표준값). 점수 절대값을 무시하고 랭크만 사용 → 다른 retriever 간
+ 스케일 차이에 강하지만, FTS의 압도적 신호도 평탄화되는 단점.
+ """
+
+ name = "rrf"
+ K = 60
+
+ def fuse(self, text_results, vector_results, query, limit):
+ from api.search import SearchResult
+
+ scores: dict[int, float] = {}
+ sources: dict[int, dict[str, SearchResult]] = {}
+
+ for rank, r in enumerate(text_results, start=1):
+ scores[r.id] = scores.get(r.id, 0.0) + 1.0 / (self.K + rank)
+ sources.setdefault(r.id, {})["text"] = r
+
+ for rank, r in enumerate(vector_results, start=1):
+ scores[r.id] = scores.get(r.id, 0.0) + 1.0 / (self.K + rank)
+ sources.setdefault(r.id, {})["vector"] = r
+
+ merged: list[SearchResult] = []
+ for doc_id, rrf_score in sorted(scores.items(), key=lambda kv: -kv[1]):
+ srcs = sources[doc_id]
+ base = srcs.get("text") or srcs.get("vector")
+ assert base is not None
+ reasons: list[str] = []
+ if "text" in srcs:
+ reasons.append(srcs["text"].match_reason or "text")
+ if "vector" in srcs:
+ reasons.append("vector")
+ merged.append(
+ SearchResult(
+ id=base.id,
+ title=base.title,
+ ai_domain=base.ai_domain,
+ ai_summary=base.ai_summary,
+ file_format=base.file_format,
+ score=rrf_score,
+ snippet=base.snippet,
+ match_reason="+".join(reasons),
+ )
+ )
+ return merged[:limit]
+
+
+# ─── 3) RRF + 강한 시그널 boost ─────────────────────────
+
+
+class RRFWithBoost(RRFOnly):
+ """RRF + 강한 시그널 boost.
+
+ RRF의 점수 평탄화를 보완하기 위해 다음 케이스에 score를 추가 가산:
+ - title 정확 substring 매치 : +0.020
+ - tags 매치 : +0.015
+ - 법령 조문 정확 매치(예 제80조): +0.050 (가장 강한 override)
+ - text score >= 5.0 : +0.010
+
+ Boost 크기는 의도적으로 적당히. RRF의 안정성은 유지하되 강한 신호는 끌어올림.
+ Phase 0.5 default 전략.
+ """
+
+ name = "rrf_boost"
+
+ BOOST_TITLE = 0.020
+ BOOST_TAGS = 0.015
+ BOOST_LEGAL_ARTICLE = 0.050
+ BOOST_HIGH_TEXT_SCORE = 0.010
+
+ LEGAL_ARTICLE_RE = re.compile(r"제\s*\d+\s*조")
+ HIGH_TEXT_SCORE_THRESHOLD = 5.0
+
+ def fuse(self, text_results, vector_results, query, limit):
+ # 일단 RRF로 후보 충분히 확보 (boost 후 재정렬되도록 limit 넓게)
+ candidates = super().fuse(text_results, vector_results, query, max(limit * 3, 30))
+
+ # 원본 text 신호 lookup
+ text_score_by_id = {r.id: r.score for r in text_results}
+ text_reason_by_id = {r.id: (r.match_reason or "") for r in text_results}
+
+ # 쿼리에 법령 조문이 있으면 그 조문 추출
+ legal_articles_in_query = set(
+ re.sub(r"\s+", "", a) for a in self.LEGAL_ARTICLE_RE.findall(query)
+ )
+
+ for result in candidates:
+ boost = 0.0
+ text_reason = text_reason_by_id.get(result.id, "")
+
+ if "title" in text_reason:
+ boost += self.BOOST_TITLE
+ elif "tags" in text_reason:
+ boost += self.BOOST_TAGS
+
+ if text_score_by_id.get(result.id, 0.0) >= self.HIGH_TEXT_SCORE_THRESHOLD:
+ boost += self.BOOST_HIGH_TEXT_SCORE
+
+ if legal_articles_in_query and result.title:
+ title_articles = set(
+ re.sub(r"\s+", "", a)
+ for a in self.LEGAL_ARTICLE_RE.findall(result.title)
+ )
+ if legal_articles_in_query & title_articles:
+ boost += self.BOOST_LEGAL_ARTICLE
+
+ if boost > 0:
+ # pydantic v2에서도 mutate 가능
+ result.score = result.score + boost
+
+ candidates.sort(key=lambda r: r.score, reverse=True)
+ return candidates[:limit]
+
+
+# ─── factory ─────────────────────────────────────────────
+
+
+_STRATEGIES: dict[str, type[FusionStrategy]] = {
+ "legacy": LegacyWeightedSum,
+ "rrf": RRFOnly,
+ "rrf_boost": RRFWithBoost,
+}
+
+DEFAULT_FUSION = "rrf_boost"
+
+
+def get_strategy(name: str) -> FusionStrategy:
+ cls = _STRATEGIES.get(name)
+ if cls is None:
+ raise ValueError(f"unknown fusion strategy: {name}")
+ return cls()
+
+
+# ─── display score 정규화 ────────────────────────────────
+
+
+def normalize_display_scores(results: list["SearchResult"]) -> None:
+ """SearchResult.score를 [0.05..1.0] 랭크 기반 값으로 in-place 갱신.
+
+ 프론트엔드는 score*100을 % 표시하므로 [0..1] 범위가 적절.
+ fusion 내부 score는 상대적 순서만 의미가 있으므로 절대값 노출 없이 랭크만 표시.
+
+ 랭크 1 → 1.0 / 랭크 2 → 0.95 / ... / 랭크 20 → 0.05 (균등 분포)
+ """
+ n = len(results)
+ if n == 0:
+ return
+ for i, r in enumerate(results):
+ # 1.0 → 0.05 사이 균등 분포
+ rank_score = 1.0 - (i / max(n - 1, 1)) * 0.95
+ r.score = round(rank_score, 4)
diff --git a/app/services/search_telemetry.py b/app/services/search_telemetry.py
index bc6e05c..eb2dbb5 100644
--- a/app/services/search_telemetry.py
+++ b/app/services/search_telemetry.py
@@ -149,6 +149,22 @@ def _cosine_to_confidence(cosine: float) -> float:
return 0.10
+def compute_confidence_hybrid(
+ text_results: list[Any],
+ vector_results: list[Any],
+) -> float:
+ """hybrid 모드 confidence — fusion 적용 *전*의 raw text/vector 결과로 계산.
+
+ Phase 0.5에서 RRF 도입 후 fused score는 절대값 의미가 사라지므로,
+ 원본 retrieval 신호의 더 강한 쪽을 confidence로 채택.
+ """
+ text_conf = compute_confidence(text_results, "fts") if text_results else 0.0
+ vector_conf = (
+ compute_confidence(vector_results, "vector") if vector_results else 0.0
+ )
+ return max(text_conf, vector_conf)
+
+
# ─── 로깅 진입점 ─────────────────────────────────────────
@@ -200,16 +216,22 @@ async def record_search_event(
user_id: int | None,
results: list[Any],
mode: str,
+ confidence: float | None = None,
) -> None:
"""검색 응답 직후 호출. 실패 트리거에 해당하면 로그 INSERT.
background task에서 await로 호출. request 세션과 분리.
user_id가 None이면 reformulation 추적 + 로깅 모두 스킵 (시스템 호출 등).
+
+ confidence 파라미터:
+ - None이면 results 기준으로 자체 계산 (legacy 호출용).
+ - 명시적으로 전달되면 그 값 사용 (Phase 0.5+: fusion 적용 전 raw 신호 기준).
"""
if user_id is None:
return
- confidence = compute_confidence(results, mode)
+ if confidence is None:
+ confidence = compute_confidence(results, mode)
result_count = len(results)
base_ctx = _build_context(results, mode, extra={"confidence": confidence})
diff --git a/frontend/src/routes/__styleguide/+page.svelte b/frontend/src/routes/__styleguide/+page.svelte
new file mode 100644
index 0000000..5e9b29a
--- /dev/null
+++ b/frontend/src/routes/__styleguide/+page.svelte
@@ -0,0 +1,452 @@
+
+
+
+ Phase A 산출물 — 토큰, 프리미티브, layer 컴포넌트 검증. dev only. +
+{t.name}
+{t.name}
+{r.name}
+variant × size
+아이콘 + 상태
+href (자동 a 변환)
+기본 padded
+bg-surface + rounded-card + border-default
+padded={false}
+내부에서 직접 padding 제어
+interactive
+클릭 시 hover + button 시맨틱
+{JSON.stringify({ textValue, textareaValue, selectValue })}
+ 📋 개요 패널 — 좌우 화살표 키로 탭 이동.
+ {:else if activeId === 'edit'} +✏️ 편집 패널.
+ {:else if activeId === 'history'} +🕓 이력 패널.
+ {/if} ++ 둘 다 열어 보세요. sidebar 열려 있을 때 meta를 누르면 sidebar가 자동으로 닫힙니다. +
++ Simple modal 안에서 "또 열기"를 누르면 nested modal이 위에 쌓입니다. Esc는 가장 위 modal부터 닫습니다. +
+현재 토스트는 기존 +layout.svelte 렌더러 사용 — A-8에서 토큰 swap 예정.
+id="sidebar". 단일 slot이므로 meta drawer가 열리면 자동으로 닫힙니다.
+ +id="meta". 모바일/태블릿에서 메타 패널 폴백으로 쓰일 layer.
+ +이 모달은 stack에 1개만 있습니다. 안에서 "또 열기"를 누르면 nested modal이 쌓입니다.
+stack 인덱스로 z-index가 자동 계산되어 backdrop이 정확히 겹칩니다.
+nested modal입니다. Esc를 누르면 이것만 닫히고, 아래 simple modal은 그대로 남습니다.
+ {#snippet footer()} + + {/snippet} +