diff --git a/app/api/search.py b/app/api/search.py index d1efcc9..d5b1fca 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -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]: - """트리그램 부분매칭 (한국어 지원)""" + """트리그램 부분매칭 + ILIKE fallback (한국어 지원)""" + # threshold 낮춰서 한국어 매칭 향상 + await session.execute(text("SET pg_trgm.similarity_threshold = 0.1")) result = await session.execute( text(""" SELECT id, title, ai_domain, ai_summary, file_format, - similarity( - coalesce(title, '') || ' ' || coalesce(extracted_text, ''), - :query + GREATEST( + similarity(coalesce(title, '') || ' ' || coalesce(extracted_text, ''), :query), + CASE WHEN (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) ILIKE '%' || :query || '%' + THEN 0.5 ELSE 0 END ) AS score, left(extracted_text, 200) AS snippet FROM documents WHERE (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) %% :query + OR (coalesce(title, '') || ' ' || coalesce(extracted_text, '')) ILIKE '%' || :query || '%' ORDER BY score DESC LIMIT :limit """), @@ -182,8 +186,9 @@ async def _search_hybrid(session: AsyncSession, query: str, limit: int) -> list[ FROM documents d {vector_clause} WHERE coalesce(d.extracted_text, '') != '' + OR (coalesce(d.title, '') || ' ' || coalesce(d.extracted_text, '')) ILIKE '%' || :query || '%' ) sub - WHERE sub.score > 0.01 + WHERE sub.score > 0.001 ORDER BY sub.score DESC LIMIT :limit """), diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 019fab8..43b74d9 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -63,27 +63,31 @@ } } - function handleSearchInput() { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - const params = new URLSearchParams($page.url.searchParams); - params.delete('page'); - if (searchQuery.trim()) { - params.set('q', searchQuery.trim()); - } else { - params.delete('q'); - } - if (searchMode !== 'hybrid') { - params.set('mode', searchMode); - } else { - params.delete('mode'); - } - for (const [key, val] of [...params.entries()]) { - if (!val) params.delete(key); - } - const qs = params.toString(); - goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); - }, 300); + function submitSearch() { + const params = new URLSearchParams($page.url.searchParams); + params.delete('page'); + if (searchQuery.trim()) { + params.set('q', searchQuery.trim()); + } else { + params.delete('q'); + } + if (searchMode !== 'hybrid') { + params.set('mode', searchMode); + } else { + params.delete('mode'); + } + for (const [key, val] of [...params.entries()]) { + if (!val) params.delete(key); + } + const qs = params.toString(); + goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); + } + + function handleSearchKeydown(e) { + if (e.key === 'Enter') { + e.preventDefault(); + submitSearch(); + } } async function doSearch(q, mode) { @@ -145,13 +149,13 @@ data-search-input type="text" bind:value={searchQuery} - oninput={handleSearchInput} - placeholder="검색어 입력... (/ 키로 포커스)" + onkeydown={handleSearchKeydown} + 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" />