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 @@ + + + + Styleguide — Document Server + + +
+
+
+

디자인 시스템 styleguide

+

+ Phase A 산출물 — 토큰, 프리미티브, layer 컴포넌트 검증. dev only. +

+
+ + +
+

색상 토큰

+
+ {#each colorTokens as t (t.name)} +
+
+

{t.name}

+
+ {/each} +
+ +

domain

+
+ {#each domainTokens as t (t.name)} +
+
+

{t.name}

+
+ {/each} +
+
+ + +
+

Radius

+
+ {#each radiusTokens as r (r.name)} +
+
+

{r.name}

+
+ {/each} +
+
+ + +
+

Button

+ +
+

variant × size

+
+ + + + +
+
+ + + + +
+ +

아이콘 + 상태

+
+ + + + + +
+ +

href (자동 a 변환)

+
+ +
+
+
+ + +
+

IconButton

+
+ + + + + + + +
+
+ + +
+

Card

+
+ +

기본 padded

+

bg-surface + rounded-card + border-default

+
+ +
+

padded={false}

+

내부에서 직접 padding 제어

+
+
+ addToast('info', 'Card 클릭됨')}> +

interactive

+

클릭 시 hover + button 시맨틱

+
+
+
+ + +
+

Badge

+
+ neutral + success + warning + error + accent +
+
+ sm neutral + 완료 + 대기 + 실패 +
+
+ + +
+

Skeleton

+
+ + + +
+ +
+ + +
+
+
+
+ + +
+

EmptyState

+
+ + + + + + + + +
+
+ + +
+

입력 프리미티브

+ +
+ + + + + + + +
+ +
+