Hacker News

Tin: full-text search for Postgres

Tin: Postgres를 위한 빠른 전문 검색 엔진

PlanetScale이 Postgres용 전문 검색 확장 기능 TIN을 GA로 공개했습니다. Boolean·구문·퍼지 검색과 BM25 랭킹, COUNT(*)를 지원하며, Stack Exchange 85GB·1억5,000만 문서 벤치마크에서 기존 인덱스보다 최대 541배 높은 처리량을 기록했다고 설명합니다.

AI 요약

PlanetScale이 TIN(Text INdex)을 Postgres용 전문 검색(full-text search) 확장 기능으로 공개했습니다. TIN은 GA(General Availability) 릴리스로 제공되며, 원문은 모든 Postgres 및 Neki 데이터베이스에서 즉시 사용할 수 있다고 설명합니다. 기본 사용법은 다음과 같습니다. `CREATE INDEX an_index_name ON table_name USING tin(text_column_name);`으로 인덱스를 만들고, `WHERE text_column_name ==> 'some words'` 형태로 검색합니다. 단순한 단어 포함 여부를 넘어 Boolean expression, phrase query, span query, fuzzy matching, wildcard, regular expression, 대소문자·악센트 접기(case and accent folding), `COUNT(*)`, BM25 기반 top-k 랭킹을 지원하는 것이 목표입니다.

■ 지원하려는 검색 시나리오

원문은 Postgres 안에서 제대로 동작하는 전문 검색 인덱스라면 검색 기능뿐 아니라 데이터베이스의 나머지 동작과도 함께 맞아야 한다고 설명합니다. 예를 들어 상품 검색에서는 여러 키워드를 모두 포함하는 상품을 찾은 뒤 `tin.score(ctid)`를 기준으로 상위 10개를 정렬할 수 있습니다. 법률 문서 검색에서는 여러 키워드 중 하나 이상을 포함하는 모든 문서를 랭킹 없이 반환할 수 있고, 사진 태그 서비스에서는 특정 구문이 붙은 사진의 정확한 개수를 `COUNT(*)`로 계산할 수 있습니다. 검색 중에도 문서가 INSERT·UPDATE·DELETE될 수 있어야 하며, 변경 사항은 커밋 직후 검색 결과에 반영되어야 합니다. 또한 JOIN, 복잡한 `WHERE` 조건, 복제(replication), 백업, 트랜잭션 가시성(transaction visibility)까지 고려해야 한다고 말합니다.

■ 벤치마크 구성과 인덱스 생성 비용

성능 측정에는 Stack Exchange 질문·답변으로 구성된 85GB, 1억5,000만 문서 코퍼스를 사용했습니다. 표준 검색 추적 데이터가 없어서 2~15개 용어로 구성된 부분 문자열을 샘플링하고, 각각을 conjunction(모든 단어 포함), disjunction(하나 이상의 단어 포함), phrase(단어가 순서대로 연속 등장) 쿼리로 해석해 총 1,719개 쿼리를 만들었습니다. Wikipedia 전체, 2.3TB 규모 Reddit 댓글, 797GB 규모의 공개 연구 논문·법률 문서·공개 저작물·Enron 이메일을 합친 `pile` 코퍼스도 측정 대상으로 사용했지만, 기사에서 공유한 주요 수치는 Stack Exchange 코퍼스에서 나왔습니다.

측정 환경은 로컬 NVMe 스토리지와 AVX-512를 지원하는 AWS i7i.8xlarge EC2 인스턴스입니다. 각 엔진은 Postgres 18.6을 격리된 컨테이너에서 8 vCPU와 32GB RAM으로 실행했습니다. `max_parallel_workers`는 40에서 8로, `shared_buffers`는 128MB에서 24GB로, `maintenance_work_mem`은 64MB에서 24GB로 조정했습니다. 비교 대상은 TIN v1.0.2, ParadeDB v0.25.2, pg_textsearch v1.4.0, Postgres 18.6의 기본 GIN 인덱스입니다. 모든 벤치마크를 완료한 것은 TIN과 ParadeDB뿐이며, 다른 엔진은 일부 작업에서 메모리 한계나 기능 제약에 걸렸습니다.

인덱스 준비·빌드·최종화에 TIN은 8분 10초가 걸렸고 인덱스 크기는 50.7GB, 필요한 RAM은 32GB였습니다. ParadeDB는 19분 20초와 52.1GB·64GB, pg_textsearch는 26분 49초와 41.5GB·128GB, Postgres GIN은 2시간 9분 4초와 28.0GB·64GB를 기록했습니다. 인덱스 생성 단계에서는 비교 엔진의 메모리를 일시적으로 늘렸지만, 쿼리 실행 전에는 모두 32GB로 되돌렸습니다.

■ 검색 처리량과 업데이트 동시성

conjunction·disjunction·phrase를 섞은 top-10 BM25 검색에서 TIN은 ParadeDB보다 초당 처리 쿼리 수(QPS)가 25배 많았고 p99 지연시간은 26배 낮았습니다. GIN은 disjunction 검색 중 메모리가 부족해 이 벤치마크를 완료하지 못했고, pg_textsearch는 disjunction만 처리할 수 있어 제외됐습니다. conjunction·phrase top-10 검색에서는 TIN이 ParadeDB보다 10배, GIN보다 541배 많은 쿼리를 처리했습니다. p99 지연시간은 각각 6배, 1,356배 낮았습니다.

기사의 표에서 conjunction·disjunction·phrase를 섞은 top-10 검색은 TIN이 읽기 전용일 때 199 QPS, p99 256ms, 쿼리당 65MB를 읽었습니다. 초당 업데이트가 함께 발생하면 172 QPS, p99 284ms, 88MB/query였고 업데이트 수는 271,398건이었습니다. 같은 조건에서 ParadeDB는 읽기 전용 7.9 QPS·p99 6,765ms·582MB/query, 업데이트 동시 실행 6 QPS·p99 7,990ms·591MB/query·193,487건의 업데이트를 기록했습니다.

disjunction top-10 검색에서는 TIN이 읽기 전용 148 QPS, p99 324ms, 48MB/query를 기록했고, 업데이트와 함께 실행할 때도 125 QPS, p99 354ms, 77MB/query를 유지했습니다. ParadeDB는 17 QPS에서 업데이트 동시 실행 시 2.2 QPS로 떨어졌고 p99는 2,385ms에서 12,634ms로 증가했습니다. pg_textsearch는 읽기와 쓰기를 함께 실행할 때 독자 쿼리가 3.5 QPS로 유지됐지만, 10분 동안 완료한 업데이트는 735건뿐이었습니다. 기사 서술 기준으로는 같은 실험에서 TIN이 270,279건, ParadeDB가 185,584건, pg_textsearch가 735건의 업데이트를 완료했습니다. TIN은 pg_textsearch보다 36배, ParadeDB보다 57배 많은 검색을 처리했고 p99 지연시간은 각각 24배, 36배 낮았습니다.

COUNT(*) 시나리오에서도 TIN은 conjunction·disjunction·phrase 혼합 검색에서 179 QPS, p99 438ms, 97MB/query를 기록했습니다. ParadeDB는 10 QPS, p99 2,704ms, 544MB/query였습니다. 8.0GB Wikipedia 코퍼스에서 disjunction 검색 결과의 개수만 세는 작업에서는 TIN이 10,260 QPS와 p99 2ms, 1.7MB/query를 기록했고, ParadeDB는 291 QPS·95ms·22MB/query, GIN은 1.4 QPS·30,292ms·2.5MB/query였습니다. TIN은 기사에 제시된 다양한 시나리오에서 대안보다 최소 8배 높은 처리량을 보였고, 디스크나 블록 캐시에서 읽는 데이터도 적었다고 설명합니다.

■ 핵심 설계: 문서 ID로 Postgres ctid 사용

TIN의 가장 중요한 설계 선택은 각 문서의 postings에 별도의 연속적인 문서 ID를 부여하지 않고 Postgres의 `ctid`를 직접 사용하는 것입니다. Postgres 테이블의 각 tuple 버전에는 물리적 위치를 나타내는 48비트 `ctid`가 붙습니다. 상위 32비트는 heap의 block 번호, 하위 16비트는 해당 block 안의 offset을 나타내며, 텍스트로는 `(block number, offset number)` 형태입니다. 예를 들어 `(190, 17)`은 190번 페이지의 17번째 슬롯에 있는 tuple을 가리킵니다. 이 값을 사용하면 해당 tuple을 heap에서 즉시 찾을 수 있고, `SELECT * FROM books WHERE ctid = '(190, 17)'`처럼 직접 조회할 수 있습니다.

대부분의 전문 검색 시스템은 인덱스를 여러 segment로 나누고, 각 segment 안의 문서에 1부터 n까지의 연속적인 ID를 부여합니다. 이 방식은 delta encoding과 bit-packing에 유리하지만, segment가 달라지면 같은 숫자의 문서 ID가 서로 다른 문서를 가리킵니다. 따라서 segment를 병합할 때 전체 문서 ID를 다시 매기고, 데이터를 재압축·재작성해야 합니다. ParadeDB와 pg_textsearch는 검색 결과의 문서 ID를 실제 Postgres `ctid`로 변환하기 위한 별도 자료구조도 유지해야 합니다. 검색 결과가 1,000만 행이면 이 매핑을 1,000만 번 조회해야 한다고 원문은 설명합니다.

TIN은 페이지 번호와 페이지 안의 offset을 분리한 2단계 bitmap으로 불연속적인 48비트 `ctid`를 압축합니다. 8KB Postgres 페이지에는 최대 291개의 tuple만 들어갈 수 있고, TEXT 같은 컬럼이 있는 일반적인 스키마에서는 페이지당 32개 이하인 경우가 많습니다. 따라서 term이 등장하는 페이지 목록은 page-level bitmap으로, 각 페이지 안의 tuple 위치는 offset-level bitmap으로 표현할 수 있습니다. 고빈도 term은 posting 하나당 약 1비트, 중간 빈도 term은 약 7비트, 희귀 term은 약 25비트까지 내려가며 한 번만 등장하는 term은 bitmap으로 저장하지 않는다고 설명합니다.

page-level bitmap은 256비트로 AVX2 이상 CPU의 vector register 하나에 들어갑니다. AND 검색에서는 256비트 단위로 페이지 bitmap을 교집합하고, 결과에 포함되지 않는 페이지는 offset bitmap을 디코드하지 않습니다. OR 검색에서는 합집합을 수행하고, 개수만 필요한 경우 CPU의 POPCNT 명령으로 결과 bitmap의 비트 수를 셉니다. 검색 결과가 행이어야 할 때는 set bit의 위치에서 `ctid`를 직접 계산하므로 별도 디스크 매핑 조회가 필요하지 않습니다. 반환되는 `ctid`가 heap 순서에 가깝게 정렬되기 때문에 Postgres가 실제 tuple을 읽을 때 순차 접근에 가까운 패턴을 얻는 점도 원문이 제시하는 이점입니다.

■ MVCC, 삭제 처리와 segment 병합

TIN은 Postgres의 MVCC 규칙에 맞춰 현재 statement의 snapshot에서 보이는 tuple만 반환해야 합니다. 실제 컬럼을 반환하는 일반적인 검색은 heap에서 tuple을 읽는 과정에서 Postgres가 visibility를 확인합니다. 반면 `COUNT(*)`처럼 인덱스에서 답을 만들 수 있는 쿼리는 모든 heap page가 all-visible로 표시돼 있으면 heap을 전혀 읽지 않고 처리할 수 있습니다. 일부 페이지가 변경된 경우에도 TIN의 page-level bitmap과 Postgres visibility map을 직접 교집합해 all-visible이 아닌 페이지만 추가 확인하도록 최적화합니다.

UPDATE나 DELETE로 tuple이 사라지면 TIN은 segment별 liveness bitmap에서 해당 `ctid`의 비트를 지웁니다. 삭제된 tuple이 있는 페이지 그룹을 표시해 두고, 쿼리가 그 그룹을 건드릴 때 postings bitmap과 liveness bitmap을 AND 연산해 이미 삭제된 tuple이 결과나 개수에 포함되지 않도록 합니다. 새 데이터와 변경 데이터는 검색 효율보다 삽입 용이성을 우선한 mutable segment에 기록되고, 백그라운드 worker가 이를 immutable segment로 승격합니다. 이후 여러 immutable segment를 더 큰 segment로 병합합니다.

TIN은 `ctid`를 사용하기 때문에 segment 병합 때 문서 ID를 다시 매길 필요가 없습니다. 각 segment에서 page bitmap과 offset bitmap의 의미가 동일하므로 기존 bitmap을 새 segment로 그대로 넘길 수 있고, 다시 압축하거나 복사하지 않아도 됩니다. 원문은 이 특성이 병합 과정의 write amplification과 CPU·I/O 비용을 줄이며, 제시된 벤치마크에서 TIN이 대안보다 최소 8배 빠른 이유라고 설명합니다.

■ Hacker News 반응

• @Tiberium — 궁금한 분들을 위해 링크를 남깁니다: https://planetscale.com/docs/postgres/search/get-started#loc... 현재 같은 성능의 로컬 확장은 제공하지 않고, 클라우드 서비스에서만 제공합니다. 로컬 버전인 https://github.com/planetscale/lead 는 주로 구문 테스트용이며, 같은 성능 특성을 갖지는 않습니다.

• @noir_lord — 그들에게 점점 더 일반적인 일이 되고 있으며 Neki도 같습니다. 그래서 언젠가 그들을 사용할 일은 즉시 배제합니다. 현재는 그런 규모의 이점이 필요한 문제가 없지만, 과거에는 그런 문제가 있었습니다. Postgres의 라이선스가 이를 허용한다는 점은 알지만, 개인적으로는 좋지 않은 뒷맛이 남습니다. 또한 이것은 실제로 ‘Postgres를 위한 전문 검색’이 아니라 ‘그들의 호스팅 Postgres 버전을 위한 전문 검색’이므로 제목이 조금 오해를 부릅니다.

• @dbbk — 로컬 테스트에 그렇게 빠른 검색이 왜 필요합니까?

• @dbbk — 이것은 Postgres 검색이므로 어떤 코딩 에이전트든 5분 안에 다른 것으로 전환할 수 있습니다.

• @zombodb — 왜 Postgres의 라이선스를 좋아하지 않습니까? 가능한 라이선스 중에서도 매우 허용적인 편입니다.

원문: PlanetScale / 번역·요약: Trawling