2026-03-17
블로그 검색에 GIN 인덱스를 적용해봤다 — 정확도도, 속도도 달라졌다
이 블로그에는 검색 기능이 있다. 키워드 검색과 벡터 유사도 검색을 결합한 하이브리드 방식인데, 기존 키워드 검색은 ILIKE로 구현되어 있었다. 글이 44개인 지금은 사실 느리지 않다. 그런데 두 가지 문제가 있었다.
첫째, 검색 결과의 랭킹이 엉망이었다. 'Spring Boot'을 검색하면, 제목에 'Spring Boot'이 정확히 들어간 글과 태그에 'Spring'이 살짝 걸린 글이 똑같은 점수 1.0으로 나왔다. ILIKE는 "매칭됨/안 됨" 두 가지만 구분하기 때문이다.
둘째, 글이 늘어나면 느려질 구조였다. ILIKE는 인덱스를 타지 못하고 매번 테이블 전체를 순차 스캔한다. 44개야 괜찮지만, 수백, 수천 개가 되면?
그래서 PostgreSQL의 Full-text Search와 GIN 인덱스를 적용했다.
기존 방식: ILIKE의 한계
기존 키워드 검색 쿼리는 이렇게 생겼다.
SELECT p.title, p.slug,
CASE WHEN (
p.title ILIKE '%Spring Boot%'
OR p.description ILIKE '%Spring Boot%'
OR array_to_string(p.tags, ',') ILIKE '%Spring Boot%'
) THEN 1.0 ELSE 0.0 END AS keyword_score
FROM posts p
WHERE p.status = 'published';EXPLAIN ANALYZE를 걸어보면:
Seq Scan on posts p (cost=0.00..14.03 rows=44 width=369)
(actual time=0.034..0.169 rows=44 loops=1)
Filter: ((status)::text = 'published'::text)
Rows Removed by Filter: 3
Execution Time: 0.189 ms44개에서 0.189ms. 당연히 빠르다. 하지만 Seq Scan이라는 게 문제다. 테이블 전체를 읽는다는 뜻이고, 데이터가 늘면 선형으로 느려진다.
그리고 결과 품질이 진짜 문제다. 'Spring Boot'을 검색했을 때:
글 제목ILIKE 점수Java 8에서 25까지 — 보일러플레이트 900줄 제거1.0Spring Cloud Config로 MSA 설정 관리 중앙화1.0Kafka 기초: 메시지 큐 vs 이벤트 스트리밍1.0Flix 프로젝트: 개발 기록 - 11.0Flix 프로젝트: 개발 기록 - 21.0
8개 글이 전부 1.0. 어떤 글이 더 관련 있는지 구분이 안 된다.
Full-text Search + GIN 인덱스 적용
tsvector와 ts_rank
PostgreSQL의 Full-text Search는 두 가지를 제공한다.
tsvector — 텍스트를 토큰(단어)으로 분해한 검색용 구조. 각 토큰의 위치와 빈도를 저장한다.
ts_rank — 쿼리와의 관련도를 0.0~1.0 사이 점수로 계산한다. 단어 빈도, 위치, 커버리지를 고려한다.
ILIKE가 "있다/없다"만 판단한다면, ts_rank는 "얼마나 관련 있는지"를 판단한다.
search_vector 컬럼 + 트리거
처음에는 Generated Column을 쓰려고 했다.
ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, ''))
) STORED;안 됐다. to_tsvector는 STABLE 함수라서 Generated Column에 사용 불가. 함수형 GIN 인덱스도 IMMUTABLE 요구로 실패했다.
트리거로 우회했다.
-- 컬럼 추가
ALTER TABLE posts ADD COLUMN search_vector tsvector;
-- 트리거 함수: INSERT/UPDATE 시 자동 갱신
CREATE OR REPLACE FUNCTION posts_search_vector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('simple',
coalesce(NEW.title, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
array_to_string(coalesce(NEW.tags, ARRAY[]::text[]), ' ')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- title, description, tags 변경 시 자동 실행
CREATE TRIGGER trg_posts_search_vector
BEFORE INSERT OR UPDATE OF title, description, tags ON posts
FOR EACH ROW
EXECUTE FUNCTION posts_search_vector_update();'simple'을 사용한 이유: Neon DB에는 'korean' 텍스트 검색 설정이 없다. 'simple'은 공백 기준으로 토큰을 분리하기 때문에 한국어+영어 혼합 콘텐츠에서 무난하게 동작한다.
GIN Partial Index
CREATE INDEX idx_posts_search_gin ON posts USING GIN (search_vector)
WHERE status = 'published';검색 대상은 항상 published 글뿐이므로 Partial Index로 범위를 한정했다.
검색 쿼리 리라이팅
CTE 기반으로 키워드 검색과 벡터 검색을 분리했다.
WITH keyword_results AS (
SELECT p.id, p.title, p.slug, p.description, p.tags, p.created_at,
ts_rank(p.search_vector, to_tsquery('simple', 'Spring & Boot')) AS keyword_score
FROM posts p
WHERE p.status = 'published'
AND p.search_vector @@ to_tsquery('simple', 'Spring & Boot')
),
vector_results AS (
SELECT DISTINCT ON (c.post_id)
c.post_id,
1 - (c.embedding <=> $1::vector) AS similarity
FROM post_chunks c
ORDER BY c.post_id, c.embedding <=> $1::vector
)
SELECT p.title, p.slug, p.description, p.tags, p.created_at,
coalesce(vr.similarity, 0) AS vector_score,
coalesce(kr.keyword_score, 0) AS keyword_score
FROM posts p
LEFT JOIN keyword_results kr ON kr.id = p.id
LEFT JOIN vector_results vr ON vr.post_id = p.id
WHERE p.status = 'published'
AND (kr.id IS NOT NULL OR vr.similarity > 0.3)
ORDER BY (0.4 * coalesce(kr.keyword_score, 0) + 0.6 * coalesce(vr.similarity, 0)) DESC
LIMIT 20;실측 결과
44개 글: 속도 차이는 미미하다
현재 블로그의 실제 데이터(44개 published 글)에서 EXPLAIN ANALYZE를 돌려봤다.
방식SQL 실행시간스캔 방식ILIKE0.189msSeq ScanGIN + ts_rank0.076msSeq Scan (옵티마이저 선택)
둘 다 1ms 미만이고, GIN을 만들어놨는데도 PostgreSQL 옵티마이저가 Seq Scan을 선택했다. 44개면 인덱스를 거치는 오버헤드보다 그냥 다 읽는 게 빠르기 때문이다. 옵티마이저는 생각보다 똑똑하다.
6,000개 글: 여기서 차이가 난다
그래서 더미 데이터 6,000개를 넣고 벤치마크를 돌려봤다. 선택도가 높은(매칭되는 행이 적은) 검색어로 테스트했다.
검색어ILIKE (Seq Scan)GIN + ts_rank개선Kafka7.136ms1.108ms6.4배pgvector7.609ms1.091ms7.0배
ILIKE는 6,000개 행을 전부 읽고 패턴 매칭을 했다(Seq Scan, 7ms). GIN + ts_rank는 인덱스로 매칭되는 행만 빠르게 찾아서 1ms 안에 끝냈다.
44개에서는 차이가 없지만, 6,000개에서는 7배 차이. 데이터가 더 늘어나면 격차는 벌어진다. ILIKE는 행 수에 비례해서 느려지지만, GIN Index Scan은 거의 일정하기 때문이다.
정확도: 이것도 확실히 달라졌다
'Spring Boot' 검색 결과의 ts_rank 점수:
글 제목ILIKEts_rankJava 8에서 25까지 — 보일러플레이트 900줄 제거1.00.4160Spring Cloud Config로 MSA 설정 관리 중앙화1.00.1993Kafka 기초: 메시지 큐 vs 이벤트 스트리밍1.00.1677Flix 프로젝트: 개발 기록 - 71.00.0991Flix 프로젝트: 아키텍처 소개 & 개발 기록 - 11.00.0991
ILIKE에서는 전부 1.0이었던 점수가, ts_rank에서는 0.0991에서 0.4160까지 4배 이상 차이가 난다. 'Spring Boot'과 직접 관련 있는 Java 마이그레이션 글이 가장 높고, 태그에만 걸린 글은 낮다.
하이브리드 검색에서 키워드 점수에 40% 가중치를 주고 있으니, 이 차별화가 최종 랭킹에 직접 영향을 준다. ILIKE 시절에는 키워드 40%가 사실상 무의미했는데, 이제 비로소 키워드 매칭과 의미 유사도가 진짜로 조합되는 하이브리드 검색이 됐다.
삽질 기록
순탄하지만은 않았다.
Generated Column 실패 —
to_tsvector가 STABLE 함수라서 불가. PostgreSQL의 함수 분류(IMMUTABLE/STABLE/VOLATILE)를 이해하고 있어야 당황하지 않는다.함수형 인덱스도 실패 — 같은 이유. IMMUTABLE이 아니면 인덱스 표현식에 사용 불가.
Korean config 없음 — Neon DB에는
'korean'텍스트 검색 설정이 없다.'simple'로 타협.44개에서 GIN 안 탐 — 옵티마이저가 Seq Scan을 선택. 인덱스를 만들었다고 무조건 타는 게 아니다.
정리
작은 데이터에서는 속도 차이가 없다. 하지만 구조를 미리 잡아두면 데이터가 늘어도 성능이 유지된다.
큰 데이터에서는 확실히 빠르다. 6,000개 기준 7배 차이, 데이터가 늘수록 격차는 벌어진다.
속도보다 더 큰 수확은 정확도다. ILIKE의 0/1 매칭이 ts_rank의 관련도 점수로 바뀌면서 검색 랭킹 품질이 눈에 띄게 좋아졌다.
PostgreSQL 옵티마이저를 믿어라. 인덱스를 만들어도 데이터가 적으면 안 탄다. 그게 오히려 효율적이기 때문이다.
'Spring Boot'을 검색했을 때 Java 마이그레이션 글이 1등으로 올라오는 걸 보면, 이 정도면 충분히 가치 있는 작업이었다.
관련 글
벡터 유사도 기반블로그 검색에 임베딩 기반 벡터 유사도를 도입한 이야기
LIKE 검색의 한계를 넘어 OpenAI 임베딩과 pgvector를 활용한 하이브리드 검색을 구현한 과정. 청킹 전략, 스코어링 방식, 점수 보정까지.
86% 일치pgvector로 블로그 관련 글 추천 구현하기
PostgreSQL의 pgvector 확장과 OpenAI 임베딩을 활용해 블로그에 관련 글 추천 기능을 구현한 과정을 정리합니다.
74% 일치개인 블로그에 RAG 기반 AI 챗봇 구축하기
pgvector + OpenAI 임베딩 + Groq LLM으로 블로그 콘텐츠 기반 RAG 챗봇을 만든 과정을 정리했습니다.