RUM—인덱스에 더 많이 저장
출처: Dev.to
이 연재 글은 GIN → RUM → Extended RUM으로의 진화를 추적한다. “인덱스에 더 많이 저장해서 쿼리 시에 하는 작업을 줄인다”는 하나의 설계 아이디어가 단계마다 큰 성능 향상을 가능하게 한다.
RUM은 Alexander Korotkov, Oleg Bartunov, Teodor Sigaev가 Postgres Professional에서 만들었다. 이는 사용자가 실제 운영 환경에서 마주치던 GIN 한계에 직접적인 대응으로 시작되었다—특히 순위 매기기와 정렬이 쿼리 시간을 좌우하는 전체 텍스트 검색 시나리오에서.
이름은 의미가 있다: GIN(술) → RUM(더 강한 술). 인덱스도 더 강력해진다.
RUM의 혁신은 정확히 하나의 설계 변경이다: 포스팅 리스트의 각 항목이 TID와 함께 추가 데이터를 담을 수 있다.
GIN 포스팅 리스트 항목: [TID]
RUM 포스팅 리스트 항목: [TID, addInfo]
A 2016년 pgsql-general 스레드에서 정확한 고충이 드러난다. Andreas Krogh는 수백만 건의 메일을 처리하고, 사용자에게 밀리초 수준의 응답을 제공해야 하는 웹 기반 이메일 시스템을 구축하고 있었다. 그는 다음을 모두 만족하는 단일 인덱스를 원했다.
- 전체 텍스트 검색어와 매치 (
fts_all @@ to_tsquery(...)) - 폴더별 필터링 (
folder_id = ANY(ARRAY[2,3])) - 타임스탬프 순 정렬 (
ORDER BY sent DESC) - 조기 종료 (
LIMIT 101)
GIN을 사용하면 1·2 단계는 (btree_gin 덕분에) 동작했지만, 3 단계는 항상 별도의 Sort 노드가 필요했다—인덱스가 타임스탬프 순으로 결과를 제공하지 못했다. 플래너는 일치하는 모든 TID를 스캔하고, 힙 튜플을 모두 가져와 정렬해야 했다. 매치가 10만 건인 메일함이라면 이는 용납할 수 없는 상황이다.
Oleg Bartunov의 직접적인 답변은 *“우리는 내부 RUM 버전을 열심히 작업하고 있다.”*였으며, RUM은 바로 이 종류의 문제를 해결하도록 설계되었다.
RUM은 order_by_attach 옵션을 통해 GIN 프레임워크를 확장한다.
CREATE INDEX idx ON documents USING rum (tsv rum_tsvector_addon_ops, created_at)
WITH (attach = 'created_at', to = 'tsv', order_by_attach = true);
이 구문은 RUM에 “tsv 포스팅 리스트의 각 TID마다 해당 created_at 값을 함께 저장하라”는 뜻이다. 포스팅 리스트 항목은 다음과 같이 된다.
"postgresql" → [(0,1, 2024-01-15), (3,7, 2024-02-20), (5,2, 2024-03-01)]
이제 포스팅 리스트는 물리적인 TID 순서가 아니라 addInfo(타임스탬프) 순으로 정렬된다.
RUM이 GIN보다 제공하는 것
- 정렬된 결과를 별도 정렬 없이 제공. 인덱스가
addInfo순으로 포스팅 리스트를 탐색하고 N개의 항목을 반환하면 된다. Sort 노드도 전체 스캔도 필요 없다. - 거리 기반 정렬 (
<->,<=>). RUM은 거리 연산자(,)를 도입한다.는 `ABS(a - b)`를 계산해 가장 가까운 순으로 검색한다.변형은 한 방향으로만 제한한다. 전체 텍스트 순위 매기기에서는 RUM의 “ 연산자가ts_rank와ts_rank_cd의 기능을 결합한 내장 순위 함수를 제공해 OR 쿼리를 단일 함수보다 더 잘 처리한다. - 깊이 우선 탐색: 첫 결과를 즉시 반환한다. GIN의 비트맵 방식(모든 TID를 모은 뒤 힙에 접근)과 달리, RUM은 깊이 우선 탐색을 수행한다.
LIMIT쿼리에서 매우 중요하다. - 재검증 없는 구문 검색. RUM의
rum_tsvector_ops는 단어 위치를addInfo로 저장한다. 인덱스가 스캔 중에 구문 인접성을 검증하므로 힙 재검증이 필요 없다.
연산자 클래스
rum_tsvector_ops– 위치 정보를 가진 어휘를 저장하고, 관련성(“)에 따라 정렬을 지원rum_tsvector_hash_ops– 위치를 가진 해시된 어휘를 저장하고, 접두사 검색 없이 정렬(“)을 지원rum_tsvector_addon_ops– 어휘 + 임의의 부착 컬럼을 저장하고, 부착 컬럼에 대해 정렬(“)을 지원rum_anyarray_ops– 배열 요소와 배열 길이를 저장하고, 유사도(“)에 따라 정렬을 지원rum_anyarray_addon_ops– 배열 요소 + 부착 컬럼을 저장하고, 부착 컬럼에 대해 정렬(“)을 지원rum_timestamp_ops– 스칼라 값을 저장하고, 정렬(“)을 지원
이전 글에서 사용한 articles 테이블에 published 타임스탬프를 tsv와 함께 저장하는 RUM 인덱스를 추가한다.
postgres=# CREATE EXTENSION IF NOT EXISTS rum;
postgres=# CREATE INDEX idx_rum_addon ON articles
USING rum (tsv rum_tsvector_addon_ops, published)
WITH (attach = 'published', to = 'tsv');
동일한 텍스트 쿼리를 사용해 published 날짜 기준으로 가장 최신 5개의 글을 검색한다.
postgres=# EXPLAIN (COSTS OFF, ANALYZE, BUFFERS, VERBOSE)
SELECT id, title, published,
published '2020-06-01'::timestamp AS distance
FROM articles
WHERE tsv @@ to_tsquery('simple', 'postgresql & article')
ORDER BY published '2020-06-01'::timestamp
LIMIT 5;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------
Limit (actual time=89.101..89.552 rows=5 loops=1)
Output: id, title, published, ((published '2020-06-01 00:00:00'::timestamp without time zone))
Buffers: shared hit=1233 read=1514, temp read=1303 written=1303
-> Index Scan using idx_rum_addon on documentdb_core.articles (actual time=89.097..89.542 rows=5 loops=1)
Output: id, title, published, (published '2020-06-01 00:00:00'::timestamp without time zone)
Index Cond: (articles.tsv @@ '''postgresql'' & ''article'''::tsquery)
Order By: (articles.published '2020-06-01 00:00:00'::timestamp without time zone)
Buffers: shared hit=1233 read=1514, temp read=1303 written=1303
Planning:
Buffers: shared hit=3
Planning Time: 0.401 ms
Execution Time: 89.747 ms
(12 rows)
Sort 노드가 없고, Bitmap도 없다. Order By가 인덱스에 푸시된 진정한 Index Scan이며, 5개의 결과를 반환하고 바로 종료한다.
다음은 구문 검색을 위해 위치 정보를 추가한 RUM 인덱스이다.
postgres=# CREATE INDEX idx_rum_pos ON articles USING rum (tsv rum_tsvector_ops);
postgres=# EXPLAIN (COSTS OFF, ANALYZE, BUFFERS, VERBOSE)
SELECT id, title
FROM articles
WHERE tsv @@ to_tsquery('simple', 'postgresql article')
LIMIT 5;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------
Limit (actual time=116.196..116.198 rows=0 loops=1)
Output: id, title
Buffers: shared hit=661
-> Index Scan using idx_rum_pos on documentdb_core.articles (actual time=116.192..116.193 rows=0 loops=1)
Output: id, title
Index Cond: (articles.tsv @@ '''postgresql'' ''article'''::tsquery)
Buffers: shared hit=661
Planning:
Buffers: shared hit=3
Planning Time: 0.223 ms
Execution Time: 117.838 ms
(11 rows)
“Rows Removed by Index Recheck”가 나타나지 않는다. 인덱스에 저장된 위치 정보 덕분에 인접성을 직접 검증할 수 있다. 같은 아이디어는 JSONB 등 전체 텍스트 검색을 넘어선 영역에서도 적용 가능하다. 예를 들어 RUM은 요소 위치를 저장해 재검증을 줄이고 성능을 향상시킬 수 있다.
동일한 인덱스를 활용해 관련성 순위 매기기도 할 수 있다.
postgres=# EXPLAIN (COSTS OFF, ANALYZE, BUFFERS, VERBOSE)
SELECT id, title,
tsv to_tsquery('english', 'postgresql | optimization') AS rank
FROM articles
WHERE tsv @@ to_tsquery('simple', 'postgresql | article')
ORDER BY tsv to_tsquery('english', 'postgresql | optimization')
LIMIT 10;