diff --git a/app/services/search/retrieval_service.py b/app/services/search/retrieval_service.py index 3fea96f..a628f49 100644 --- a/app/services/search/retrieval_service.py +++ b/app/services/search/retrieval_service.py @@ -1,11 +1,11 @@ -"""검색 후보 수집 서비스 (Phase 1.1). +"""검색 후보 수집 서비스 (Phase 1.2). -text(documents FTS + 키워드) + vector(documents.embedding) 후보를 +text(documents FTS + trigram) + vector(documents.embedding → chunks) 후보를 SearchResult 리스트로 반환. -Phase 1.1: search.py의 _search_text/_search_vector를 이전. -Phase 1.1 후속 substep: ILIKE → trigram `similarity()` + `gin_trgm_ops`. -Phase 1.2: vector retrieval을 document_chunks 테이블 기반으로 전환. +Phase 1.1a: search.py의 _search_text/_search_vector를 이전 (ILIKE 그대로). +Phase 1.2-B: ILIKE → trigram `%` + `similarity()`. ILIKE 풀 스캔 제거. +Phase 1.2-B 이후: vector retrieval을 document_chunks 테이블 기반으로 전환. """ from __future__ import annotations @@ -24,10 +24,15 @@ if TYPE_CHECKING: async def search_text( session: AsyncSession, query: str, limit: int ) -> list["SearchResult"]: - """FTS + ILIKE 필드별 가중치 검색. + """FTS + trigram 필드별 가중치 검색 (Phase 1.2-B). + WHERE: 인덱스 있는 trigram 컬럼(title, ai_summary)으로 후보 필터 + FTS 통합 인덱스 + - idx_documents_title_trgm + - idx_documents_ai_summary_trgm + - idx_documents_fts_full (title + ai_tags + ai_summary + user_note + extracted_text) + - extracted_text는 trigram threshold 0.3에서 매우 낮은 similarity → WHERE에선 FTS만 + ORDER BY: 5개 컬럼 similarity 가중 합산 + ts_rank * 2.0 가중치: title 3.0 / ai_tags 2.5 / user_note 2.0 / ai_summary 1.5 / extracted_text 1.0 - + ts_rank * 2.0 보너스. """ from api.search import SearchResult # 순환 import 회피 @@ -36,40 +41,48 @@ async def search_text( SELECT id, title, ai_domain, ai_summary, file_format, left(extracted_text, 200) AS snippet, ( - -- title 매칭 (가중치 최고) - CASE WHEN coalesce(title, '') ILIKE '%%' || :q || '%%' THEN 3.0 ELSE 0 END - -- ai_tags 매칭 (가중치 높음) - + CASE WHEN coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' THEN 2.5 ELSE 0 END - -- user_note 매칭 (가중치 높음) - + CASE WHEN coalesce(user_note, '') ILIKE '%%' || :q || '%%' THEN 2.0 ELSE 0 END - -- ai_summary 매칭 (가중치 중상) - + CASE WHEN coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' THEN 1.5 ELSE 0 END - -- extracted_text 매칭 (가중치 중간) - + CASE WHEN coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' THEN 1.0 ELSE 0 END - -- FTS 점수 (보너스) + -- 컬럼별 trigram similarity 가중 합산 + similarity(coalesce(title, ''), :q) * 3.0 + + similarity(coalesce(ai_tags::text, ''), :q) * 2.5 + + similarity(coalesce(user_note, ''), :q) * 2.0 + + similarity(coalesce(ai_summary, ''), :q) * 1.5 + + similarity(coalesce(extracted_text, ''), :q) * 1.0 + -- FTS 보너스 (idx_documents_fts_full 활용) + coalesce(ts_rank( - to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, '')), + to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(ai_tags::text, '') || ' ' || + coalesce(ai_summary, '') || ' ' || + coalesce(user_note, '') || ' ' || + coalesce(extracted_text, '') + ), plainto_tsquery('simple', :q) ), 0) * 2.0 ) AS score, - -- match reason + -- match_reason: similarity 가장 큰 컬럼 또는 FTS CASE - WHEN coalesce(title, '') ILIKE '%%' || :q || '%%' THEN 'title' - WHEN coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' THEN 'tags' - WHEN coalesce(user_note, '') ILIKE '%%' || :q || '%%' THEN 'note' - WHEN coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' THEN 'summary' - WHEN coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' THEN 'content' + WHEN similarity(coalesce(title, ''), :q) >= 0.3 THEN 'title' + WHEN similarity(coalesce(ai_tags::text, ''), :q) >= 0.3 THEN 'tags' + WHEN similarity(coalesce(user_note, ''), :q) >= 0.3 THEN 'note' + WHEN similarity(coalesce(ai_summary, ''), :q) >= 0.3 THEN 'summary' + WHEN similarity(coalesce(extracted_text, ''), :q) >= 0.3 THEN 'content' ELSE 'fts' END AS match_reason FROM documents WHERE deleted_at IS NULL - AND (coalesce(title, '') ILIKE '%%' || :q || '%%' - OR coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' - OR coalesce(user_note, '') ILIKE '%%' || :q || '%%' - OR coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' - OR coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' - OR to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, '')) - @@ plainto_tsquery('simple', :q)) + AND ( + -- trigram 후보 필터 (인덱스 있는 짧은 컬럼만) + title % :q + OR (ai_summary IS NOT NULL AND ai_summary % :q) + -- FTS 통합 인덱스 + OR to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(ai_tags::text, '') || ' ' || + coalesce(ai_summary, '') || ' ' || + coalesce(user_note, '') || ' ' || + coalesce(extracted_text, '') + ) @@ plainto_tsquery('simple', :q) + ) ORDER BY score DESC LIMIT :limit """), diff --git a/migrations/016_fts_expand_and_trgm.sql b/migrations/016_fts_expand_and_trgm.sql new file mode 100644 index 0000000..54c2bda --- /dev/null +++ b/migrations/016_fts_expand_and_trgm.sql @@ -0,0 +1,47 @@ +-- Phase 1.2: documents 테이블 FTS 확장 + trigram 인덱스 +-- +-- 목적: +-- 1) FTS 인덱스를 title + ai_tags + ai_summary + user_note + extracted_text 통합 범위로 확장 +-- 현재 retrieval_service.search_text의 SQL 안 to_tsvector(...)는 인덱스 없이 동작. +-- 2) trigram 인덱스로 ILIKE 풀스캔(text_ms 470ms)을 similarity() + GIN 인덱스로 대체. +-- +-- 데이터 규모 (2026-04-07 측정): documents 765 / 평균 본문 8.5KB / 총 6.5MB +-- 인덱스 빌드 시간 추산: 5~30초 (CONCURRENTLY 불필요, 짧은 lock 수용 가능) +-- +-- Phase 1.2-A 단독 적용. 1.2-B에서 retrieval_service.search_text의 SQL을 +-- ILIKE → similarity() + `%` 연산자로 전환하면서 이 인덱스들을 활용. + +-- pg_trgm extension (014에서 이미 활성화, IF NOT EXISTS로 안전) +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- ─── 1) 통합 FTS 인덱스 ──────────────────────────────────── +-- title + ai_tags(JSONB→text) + ai_summary + user_note + extracted_text를 한 번에 토큰화. +-- retrieval_service.search_text의 ts_rank 호출이 이 인덱스를 사용하도록 SQL 갱신 예정. +CREATE INDEX IF NOT EXISTS idx_documents_fts_full ON documents + USING GIN ( + to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(ai_tags::text, '') || ' ' || + coalesce(ai_summary, '') || ' ' || + coalesce(user_note, '') || ' ' || + coalesce(extracted_text, '') + ) + ); + +-- ─── 2) title trigram 인덱스 ─────────────────────────────── +-- 가장 자주 매칭되는 컬럼. similarity(title, query) > threshold + ORDER BY로 사용. +CREATE INDEX IF NOT EXISTS idx_documents_title_trgm ON documents + USING GIN (title gin_trgm_ops); + +-- ─── 3) extracted_text trigram 인덱스 ────────────────────── +-- ILIKE의 dominant cost를 trigram GIN 인덱스로 대체. +-- WHERE 절로 NULL/빈 본문 제외해 인덱스 크기 절감. +CREATE INDEX IF NOT EXISTS idx_documents_extracted_text_trgm ON documents + USING GIN (extracted_text gin_trgm_ops) + WHERE extracted_text IS NOT NULL AND length(extracted_text) > 0; + +-- ─── 4) ai_summary trigram 인덱스 ────────────────────────── +-- summary는 짧지만 의미 매칭에 자주 활용 (가중치 1.5). +CREATE INDEX IF NOT EXISTS idx_documents_ai_summary_trgm ON documents + USING GIN (ai_summary gin_trgm_ops) + WHERE ai_summary IS NOT NULL AND length(ai_summary) > 0;