본문으로 건너뛰기
wooncloud

Postgres 전문검색이 느리면 ts_headline 부터 의심하라 (스니펫 입력 캡)

전문검색이 느린 원인은 인덱스가 아니라 스니펫을 만드는 ts_headline 이었습니다. 입력을 앞 8KB 로 캡해 266ms 를 3ms 로 줄인 과정을 정리했습니다.

·4 min read·— views··

전문검색(FTS)에 결과 미리보기 스니펫(매칭어 하이라이트)을 붙였더니, 동시 사용자가 늘면서 검색 지연이 비정상적으로 치솟았습니다. 단일 검색 쿼리 하나가 약 265ms 였습니다.

인덱스가 문제가 아니었습니다

인덱스가 없나 싶어 EXPLAIN (ANALYZE, BUFFERS) 를 돌렸는데 의외였습니다.

Limit (actual time=131..266 rows=2)
  └ Result (ts_headline)   약 265ms   <- 거의 전부
      └ Sort + Index/Seq Scan   약 1.4ms   <- 스캔은 사실상 공짜

스캔과 정렬은 1ms 대인데 ts_headline()(스니펫 생성) 노드 하나가 265ms 를 먹고 있었습니다.

이유는 ts_headline 이 하이라이트 위치를 찾으려고 원본 문서 전체를 다시 토크나이즈하기 때문입니다. body 컬럼은 평균 100KB 대(추출된 오피스, PDF 본문)였고 최대는 수백 KB 였습니다. 행당 100ms 이상이 나왔고, 이게 CPU 를 태워 동시성 아래서 DB 가 포화됐습니다.

입력을 잘라도 결과는 그대로입니다

어떤 행이 검색되는지는 tsvector GIN 인덱스가 결정합니다. ts_headline 은 오직 미리보기 스니펫 텍스트만 만듭니다.

그러면 입력을 LEFT(body, N) 으로 잘라도 검색 결과 집합은 그대로고, 스니펫이 문서 앞부분만 본다는 정도의 차이만 생깁니다.

입력을 앞 8KB 정도로 캡하니 266ms 가 3ms 가 됐습니다. 약 88배입니다.

-- before: 문서 전체를 재토큰화하니 느리다
SELECT id,
       ts_headline('simple', body,
                   plainto_tsquery('simple', $1)) AS snippet
  FROM documents
 WHERE search_tsv @@ plainto_tsquery('simple', $1)   -- 매칭은 여기(GIN)서 결정
 ORDER BY ts_rank(search_tsv, plainto_tsquery('simple', $1)) DESC
 LIMIT 50;
 
-- after: 스니펫 입력만 캡한다. 결과는 동일하고 88배 빠르다
SELECT id,
       ts_headline('simple', LEFT(body, 8000),       -- 스니펫 입력만 제한
                   plainto_tsquery('simple', $1)) AS snippet
  FROM documents
 WHERE search_tsv @@ plainto_tsquery('simple', $1)
 ORDER BY ts_rank(search_tsv, plainto_tsquery('simple', $1)) DESC
 LIMIT 50;

여기서 더 줄일 수도 있습니다. 스니펫을 과다 인출한 후보 전부에 만들지 말고, 권한 필터와 페이지를 적용한 뒤 실제로 보여 줄 행에만 만들면 호출 횟수 자체가 줄어듭니다.

FTS 가 느리면 보통 인덱스부터 의심하게 되는데, 스니펫 하이라이팅 비용은 문서 길이에 비례합니다. 큰 문서를 다룬다면 ts_headline 입력부터 캡해 볼 만합니다. 매칭 정확도는 인덱스가 책임지니까요.

참고