fix: 검색 UX 개선 — Enter 키 기반 + 한국어 검색 ILIKE fallback

- 프론트: debounce 자동검색 제거 → Enter 키로만 검색 (한글 조합 문제 해결)
- 백엔드: trgm threshold 0.1로 낮춤 + ILIKE '%검색어%' fallback 추가
- hybrid 검색 score threshold 0.01 → 0.001로 낮춤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-03 14:10:47 +09:00
parent b54cc25650
commit 4d205b67c2
2 changed files with 38 additions and 29 deletions

View File

@@ -93,17 +93,21 @@ async def _search_fts(session: AsyncSession, query: str, limit: int) -> list[Sea
async def _search_trgm(session: AsyncSession, query: str, limit: int) -> list[SearchResult]: async def _search_trgm(session: AsyncSession, query: str, limit: int) -> list[SearchResult]:
"""트리그램 부분매칭 (한국어 지원)""" """트리그램 부분매칭 + ILIKE fallback (한국어 지원)"""
# threshold 낮춰서 한국어 매칭 향상
await session.execute(text("SET pg_trgm.similarity_threshold = 0.1"))
result = await session.execute( result = await session.execute(
text(""" text("""
SELECT id, title, ai_domain, ai_summary, file_format, SELECT id, title, ai_domain, ai_summary, file_format,
similarity( GREATEST(
coalesce(title, '') || ' ' || coalesce(extracted_text, ''), similarity(coalesce(title, '') || ' ' || coalesce(extracted_text, ''), :query),
:query CASE WHEN (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) ILIKE '%' || :query || '%'
THEN 0.5 ELSE 0 END
) AS score, ) AS score,
left(extracted_text, 200) AS snippet left(extracted_text, 200) AS snippet
FROM documents FROM documents
WHERE (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) %% :query WHERE (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) %% :query
OR (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) ILIKE '%' || :query || '%'
ORDER BY score DESC ORDER BY score DESC
LIMIT :limit LIMIT :limit
"""), """),
@@ -182,8 +186,9 @@ async def _search_hybrid(session: AsyncSession, query: str, limit: int) -> list[
FROM documents d FROM documents d
{vector_clause} {vector_clause}
WHERE coalesce(d.extracted_text, '') != '' WHERE coalesce(d.extracted_text, '') != ''
OR (coalesce(d.title, '') || ' ' || coalesce(d.extracted_text, '')) ILIKE '%' || :query || '%'
) sub ) sub
WHERE sub.score > 0.01 WHERE sub.score > 0.001
ORDER BY sub.score DESC ORDER BY sub.score DESC
LIMIT :limit LIMIT :limit
"""), """),

View File

@@ -63,27 +63,31 @@
} }
} }
function handleSearchInput() { function submitSearch() {
clearTimeout(debounceTimer); const params = new URLSearchParams($page.url.searchParams);
debounceTimer = setTimeout(() => { params.delete('page');
const params = new URLSearchParams($page.url.searchParams); if (searchQuery.trim()) {
params.delete('page'); params.set('q', searchQuery.trim());
if (searchQuery.trim()) { } else {
params.set('q', searchQuery.trim()); params.delete('q');
} else { }
params.delete('q'); if (searchMode !== 'hybrid') {
} params.set('mode', searchMode);
if (searchMode !== 'hybrid') { } else {
params.set('mode', searchMode); params.delete('mode');
} else { }
params.delete('mode'); for (const [key, val] of [...params.entries()]) {
} if (!val) params.delete(key);
for (const [key, val] of [...params.entries()]) { }
if (!val) params.delete(key); const qs = params.toString();
} goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
const qs = params.toString(); }
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}, 300); function handleSearchKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
submitSearch();
}
} }
async function doSearch(q, mode) { async function doSearch(q, mode) {
@@ -145,13 +149,13 @@
data-search-input data-search-input
type="text" type="text"
bind:value={searchQuery} bind:value={searchQuery}
oninput={handleSearchInput} onkeydown={handleSearchKeydown}
placeholder="검색어 입력... (/ 키로 포커스)" placeholder="검색어 입력 후 Enter (/ 키로 포커스)"
class="flex-1 px-3 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-sm focus:border-[var(--accent)] outline-none" class="flex-1 px-3 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-sm focus:border-[var(--accent)] outline-none"
/> />
<select <select
bind:value={searchMode} bind:value={searchMode}
onchange={() => { if (searchQuery) handleSearchInput(); }} onchange={() => { if (searchQuery) submitSearch(); }}
class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-xs" class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-xs"
> >
<option value="hybrid">하이브리드</option> <option value="hybrid">하이브리드</option>