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

@@ -0,0 +1,452 @@
<script lang="ts">
// 디자인 시스템 styleguide. dev only — +page.ts 에서 가드.
// Phase A 산출물 전체 시각 검증 + Modal stack / Drawer 동작 데모.
import {
Search,
Mail,
User,
Trash2,
Plus,
Save,
ExternalLink,
Settings,
Inbox,
FileText,
Link2,
Menu,
} from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Drawer from '$lib/components/ui/Drawer.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import { ui } from '$lib/stores/uiState.svelte';
import { addToast } from '$lib/stores/toast';
// ── 폼 데모 상태 ────────────────────────────────────────
let textValue = $state('');
let textWithError = $state('잘못된값');
let textareaValue = $state('');
let selectValue = $state('');
let tabValue = $state('overview');
// ── Modal 데모 상태 ─────────────────────────────────────
function openSimpleModal() {
ui.openModal('demo-simple');
}
function openNestedModal() {
ui.openModal('demo-nested');
}
function openConfirm() {
ui.openModal('demo-confirm');
}
function handleConfirm() {
addToast('success', '확인 처리됨');
}
// ── Drawer 데모 ────────────────────────────────────────
function openSidebarDrawer() {
ui.openDrawer('sidebar');
}
function openMetaDrawer() {
ui.openDrawer('meta');
}
// ── 색상 토큰 (시각 swatch용) ────────────────────────────
const colorTokens = [
{ name: 'bg', class: 'bg-bg' },
{ name: 'surface', class: 'bg-surface' },
{ name: 'surface-hover', class: 'bg-surface-hover' },
{ name: 'surface-active', class: 'bg-surface-active' },
{ name: 'sidebar', class: 'bg-sidebar' },
{ name: 'default (border)', class: 'bg-default' },
{ name: 'border-strong', class: 'bg-border-strong' },
{ name: 'text', class: 'bg-text' },
{ name: 'dim', class: 'bg-dim' },
{ name: 'faint', class: 'bg-faint' },
{ name: 'accent', class: 'bg-accent' },
{ name: 'accent-hover', class: 'bg-accent-hover' },
{ name: 'success', class: 'bg-success' },
{ name: 'warning', class: 'bg-warning' },
{ name: 'error', class: 'bg-error' },
];
const domainTokens = [
{ name: 'philosophy', class: 'bg-domain-philosophy' },
{ name: 'language', class: 'bg-domain-language' },
{ name: 'engineering', class: 'bg-domain-engineering' },
{ name: 'safety', class: 'bg-domain-safety' },
{ name: 'programming', class: 'bg-domain-programming' },
{ name: 'general', class: 'bg-domain-general' },
{ name: 'reference', class: 'bg-domain-reference' },
];
const radiusTokens = [
{ name: 'sm', class: 'rounded-sm' },
{ name: 'md', class: 'rounded-md' },
{ name: 'lg', class: 'rounded-lg' },
{ name: 'card', class: 'rounded-card' },
];
const selectOptions = [
{ value: 'knowledge', label: 'Knowledge' },
{ value: 'reference', label: 'Reference' },
{ value: 'inbox', label: 'Inbox' },
{ value: 'archived', label: 'Archived (disabled)', disabled: true },
];
const tabs = [
{ id: 'overview', label: '개요' },
{ id: 'edit', label: '편집' },
{ id: 'history', label: '이력' },
{ id: 'disabled', label: '비활성', disabled: true },
];
</script>
<svelte:head>
<title>Styleguide — Document Server</title>
</svelte:head>
<div class="h-full overflow-y-auto">
<div class="max-w-5xl mx-auto p-6 space-y-12">
<header class="border-b border-default pb-4">
<h1 class="text-2xl font-bold text-text">디자인 시스템 styleguide</h1>
<p class="text-sm text-dim mt-1">
Phase A 산출물 — 토큰, 프리미티브, layer 컴포넌트 검증. <span class="text-faint">dev only.</span>
</p>
</header>
<!-- ═════ 색상 토큰 ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">색상 토큰</h2>
<div class="grid grid-cols-3 md:grid-cols-5 gap-3">
{#each colorTokens as t (t.name)}
<div class="space-y-1.5">
<div class={'h-12 rounded-md border border-default ' + t.class}></div>
<p class="text-[10px] text-dim font-mono">{t.name}</p>
</div>
{/each}
</div>
<h3 class="text-xs font-semibold text-faint uppercase pt-3">domain</h3>
<div class="grid grid-cols-3 md:grid-cols-7 gap-3">
{#each domainTokens as t (t.name)}
<div class="space-y-1.5">
<div class={'h-12 rounded-md border border-default ' + t.class}></div>
<p class="text-[10px] text-dim font-mono">{t.name}</p>
</div>
{/each}
</div>
</section>
<!-- ═════ Radius ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Radius</h2>
<div class="flex flex-wrap gap-4">
{#each radiusTokens as r (r.name)}
<div class="space-y-1.5 text-center">
<div class={'w-16 h-16 bg-surface border border-default ' + r.class}></div>
<p class="text-[10px] text-dim font-mono">{r.name}</p>
</div>
{/each}
</div>
</section>
<!-- ═════ Button ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Button</h2>
<div class="space-y-3">
<p class="text-xs text-faint">variant × size</p>
<div class="flex flex-wrap gap-2">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button variant="primary" size="sm">sm Primary</Button>
<Button variant="secondary" size="sm">sm Secondary</Button>
<Button variant="ghost" size="sm">sm Ghost</Button>
<Button variant="danger" size="sm">sm Danger</Button>
</div>
<p class="text-xs text-faint pt-2">아이콘 + 상태</p>
<div class="flex flex-wrap gap-2">
<Button variant="primary" icon={Plus}> 문서</Button>
<Button variant="secondary" icon={Save}>저장</Button>
<Button variant="secondary" icon={ExternalLink} iconPosition="right">새 탭에서 열기</Button>
<Button variant="primary" loading>저장 중…</Button>
<Button variant="secondary" disabled>비활성</Button>
</div>
<p class="text-xs text-faint pt-2">href (자동 a 변환)</p>
<div class="flex flex-wrap gap-2">
<Button variant="ghost" href="/" icon={ExternalLink}>홈으로</Button>
</div>
</div>
</section>
<!-- ═════ IconButton ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">IconButton</h2>
<div class="flex flex-wrap gap-2 items-center">
<IconButton icon={Menu} aria-label="메뉴" variant="ghost" />
<IconButton icon={Search} aria-label="검색" variant="ghost" />
<IconButton icon={Settings} aria-label="설정" variant="secondary" />
<IconButton icon={Save} aria-label="저장" variant="primary" />
<IconButton icon={Trash2} aria-label="삭제" variant="danger" />
<IconButton icon={Save} aria-label="저장 " variant="primary" loading />
<IconButton icon={Trash2} aria-label="비활성" variant="ghost" disabled />
</div>
</section>
<!-- ═════ Card ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Card</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<p class="text-sm font-medium text-text">기본 padded</p>
<p class="text-xs text-dim mt-1">bg-surface + rounded-card + border-default</p>
</Card>
<Card padded={false}>
<div class="p-3">
<p class="text-sm font-medium text-text">padded={false}</p>
<p class="text-xs text-dim mt-1">내부에서 직접 padding 제어</p>
</div>
</Card>
<Card interactive onclick={() => addToast('info', 'Card 클릭됨')}>
<p class="text-sm font-medium text-text">interactive</p>
<p class="text-xs text-dim mt-1">클릭 시 hover + button 시맨틱</p>
</Card>
</div>
</section>
<!-- ═════ Badge ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Badge</h2>
<div class="flex flex-wrap gap-2 items-center">
<Badge tone="neutral">neutral</Badge>
<Badge tone="success">success</Badge>
<Badge tone="warning">warning</Badge>
<Badge tone="error">error</Badge>
<Badge tone="accent">accent</Badge>
</div>
<div class="flex flex-wrap gap-2 items-center">
<Badge tone="neutral" size="sm">sm neutral</Badge>
<Badge tone="success" size="sm">완료</Badge>
<Badge tone="warning" size="sm">대기</Badge>
<Badge tone="error" size="sm">실패</Badge>
</div>
</section>
<!-- ═════ Skeleton ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Skeleton</h2>
<div class="space-y-2">
<Skeleton w="w-1/3" h="h-3" />
<Skeleton w="w-2/3" h="h-4" />
<Skeleton w="w-full" h="h-20" rounded="card" />
<div class="flex gap-3 items-center">
<Skeleton w="w-12" h="h-12" rounded="full" />
<div class="flex-1 space-y-2">
<Skeleton h="h-3" w="w-1/2" />
<Skeleton h="h-3" w="w-3/4" />
</div>
</div>
</div>
</section>
<!-- ═════ EmptyState ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">EmptyState</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card padded={false}>
<EmptyState
icon={Inbox}
title="Inbox가 비어 있습니다"
description="새 파일이 들어오면 자동으로 표시됩니다."
/>
</Card>
<Card padded={false}>
<EmptyState
icon={Link2}
title="추후 지원"
description="벡터 유사도 기반 추천은 백엔드 API 추가 후 활성화됩니다."
>
<Button variant="secondary" size="sm" icon={ExternalLink}>관련 이슈 보기</Button>
</EmptyState>
</Card>
</div>
</section>
<!-- ═════ TextInput / Textarea / Select ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">입력 프리미티브</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<TextInput
label="제목"
placeholder="문서 제목 입력"
hint="여기에 도움말이 들어갑니다."
bind:value={textValue}
/>
<TextInput
label="이메일"
type="email"
leadingIcon={Mail}
placeholder="you@example.com"
autocomplete="email"
/>
<TextInput
label="검색어"
placeholder="키워드"
leadingIcon={Search}
trailingIcon={ExternalLink}
/>
<TextInput label="에러 상태" bind:value={textWithError} error="값이 유효하지 않습니다." />
<TextInput label="비활성" placeholder="readonly + disabled" disabled value="고정값" />
<TextInput label="필수" required placeholder="반드시 입력" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Textarea
label="메모"
placeholder="자유 형식 메모"
rows={3}
bind:value={textareaValue}
hint="autoGrow={'{false}'} 기본"
/>
<Textarea label="autoGrow" placeholder="입력하면 늘어남" autoGrow maxRows={6} />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Select
label="도메인"
options={selectOptions}
placeholder="선택…"
bind:value={selectValue}
/>
<Select label="에러" options={selectOptions} error="필수 항목" />
<Select label="비활성" options={selectOptions} disabled value="knowledge" />
</div>
<div class="text-xs text-faint">
현재 값:
<code class="text-text">{JSON.stringify({ textValue, textareaValue, selectValue })}</code>
</div>
</section>
<!-- ═════ Tabs ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Tabs</h2>
<Card padded={false}>
<Tabs {tabs} bind:value={tabValue}>
{#snippet children(activeId)}
<div class="p-5">
{#if activeId === 'overview'}
<p class="text-sm text-text">📋 개요 패널 — 좌우 화살표 키로 탭 이동.</p>
{:else if activeId === 'edit'}
<p class="text-sm text-text">✏️ 편집 패널.</p>
{:else if activeId === 'history'}
<p class="text-sm text-text">🕓 이력 패널.</p>
{/if}
</div>
{/snippet}
</Tabs>
</Card>
</section>
<!-- ═════ Drawer ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Drawer (단일 slot)</h2>
<p class="text-xs text-faint">
둘 다 열어 보세요. sidebar 열려 있을 때 meta를 누르면 sidebar가 자동으로 닫힙니다.
</p>
<div class="flex gap-2">
<Button variant="secondary" icon={Menu} onclick={openSidebarDrawer}>Sidebar drawer</Button>
<Button variant="secondary" icon={FileText} onclick={openMetaDrawer}>Meta drawer (오른쪽)</Button>
</div>
</section>
<!-- ═════ Modal ═════ -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Modal (stack 지원)</h2>
<p class="text-xs text-faint">
Simple modal 안에서 "또 열기"를 누르면 nested modal이 위에 쌓입니다. Esc는 가장 위 modal부터 닫습니다.
</p>
<div class="flex flex-wrap gap-2">
<Button variant="primary" onclick={openSimpleModal}>Simple Modal 열기</Button>
<Button variant="danger" icon={Trash2} onclick={openConfirm}>ConfirmDialog 열기</Button>
</div>
</section>
<!-- ═════ Toast ═════ -->
<section class="space-y-3 pb-12">
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Toast</h2>
<p class="text-xs text-faint">현재 토스트는 기존 +layout.svelte 렌더러 사용 — A-8에서 토큰 swap 예정.</p>
<div class="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onclick={() => addToast('success', '저장 완료')}>success</Button>
<Button variant="ghost" size="sm" onclick={() => addToast('error', '저장 실패')}>error</Button>
<Button variant="ghost" size="sm" onclick={() => addToast('warning', '경고')}>warning</Button>
<Button variant="ghost" size="sm" onclick={() => addToast('info', '정보')}>info</Button>
</div>
</section>
</div>
</div>
<!-- ═════ 데모용 Drawer/Modal 인스턴스 ═════ -->
<Drawer id="sidebar" side="left" width="sidebar" aria-label="Sidebar drawer 데모">
<div class="p-5 space-y-3">
<h3 class="text-sm font-semibold text-text">Sidebar drawer</h3>
<p class="text-xs text-dim">id="sidebar". 단일 slot이므로 meta drawer가 열리면 자동으로 닫힙니다.</p>
<Button variant="ghost" size="sm" onclick={() => ui.closeDrawer()}>닫기</Button>
</div>
</Drawer>
<Drawer id="meta" side="right" width="rail" aria-label="Meta drawer 데모">
<div class="p-5 space-y-3">
<h3 class="text-sm font-semibold text-text">Meta drawer</h3>
<p class="text-xs text-dim">id="meta". 모바일/태블릿에서 메타 패널 폴백으로 쓰일 layer.</p>
<Button variant="ghost" size="sm" onclick={() => ui.closeDrawer()}>닫기</Button>
</div>
</Drawer>
<Modal id="demo-simple" title="Simple modal">
<div class="space-y-3">
<p>이 모달은 stack에 1개만 있습니다. 안에서 "또 열기"를 누르면 nested modal이 쌓입니다.</p>
<p class="text-xs text-faint">stack 인덱스로 z-index가 자동 계산되어 backdrop이 정확히 겹칩니다.</p>
</div>
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('demo-simple')}>닫기</Button>
<Button variant="primary" size="sm" onclick={openNestedModal}> 열기 (nested)</Button>
{/snippet}
</Modal>
<Modal id="demo-nested" title="Nested modal" size="sm">
<p>nested modal입니다. Esc를 누르면 이것만 닫히고, 아래 simple modal은 그대로 남습니다.</p>
{#snippet footer()}
<Button variant="primary" size="sm" onclick={() => ui.closeTopModal()}>이것만 닫기</Button>
{/snippet}
</Modal>
<ConfirmDialog
id="demo-confirm"
title="문서를 삭제할까요?"
message="이 작업은 되돌릴 수 없습니다. 선택한 문서가 영구적으로 제거됩니다."
confirmLabel="삭제"
tone="danger"
onconfirm={handleConfirm}
/>

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
// dev 모드에서만 접근 가능. 프로덕션 빌드에서는 / 로 리다이렉트.
export const load = () => {
if (!dev) {
throw redirect(307, '/');
}
};