본문으로 건너뛰기

© 2026 Molayo

HN요약2026. 05. 20. 19:21

Show HN: BM25 관련성 순위 기반 전체 텍스트 검색을 위한 Postgres 확장 기능

요약

pg_textsearch는 PostgreSQL에서 BM25 랭킹 알고리즘을 사용하여 현대적인 순위 기반 전체 텍스트 검색을 지원하는 확장 기능입니다. Block-Max WAND 최적화와 병렬 인덱스 빌드를 통해 대규모 데이터셋에서도 빠른 top-k 쿼리 성능과 높은 확장성을 제공합니다.

핵심 포인트

  • BM25 랭킹 알고리즘 지원 (k1, b 파라미터 설정 가능)
  • Block-Max WAND 최적화를 통한 고성능 top-k 검색 구현
  • JSONB 필드, 다중 컬럼 검색 및 표현식 인덱스 지원
  • PostgreSQL 17 및 18 버전 호환 및 파티션 테이블 지원
  • 대규모 테이블을 위한 병렬 인덱스 빌드 기능 제공

pg_textsearch



Postgres를 위한 현대적인 순위 기반 텍스트 검색 (ranked text search).

  • 간단한 구문: ORDER BY content <@> 'search terms'
  • 설정 가능한 파라미터 (k1, b)를 사용하는 BM25 랭킹 (BM25 ranking)
  • Postgres 텍스트 검색 설정 (english, french, german 등)과 연동
  • JSONB 필드, 다중 컬럼 검색 및 텍스트 변환을 위한 표현식 인덱스 (Expression indexes)
  • 범위 제한 검색 및 다국어 테이블을 위한 부분 인덱스 (Partial indexes)
  • Block-Max WAND 최적화를 통한 빠른 top-k 쿼리
  • 대규모 테이블을 위한 병렬 인덱스 빌드 (Parallel index builds)
  • 파티션 테이블 (partitioned tables) 지원
  • 동급 최고의 성능 및 확장성

🚀 상태 (Status): v1.3.0-dev - 프로덕션 준비 완료 (Production ready).

역사적 참고 사항 (Historical note)

이 프로젝트의 원래 이름은 Tapir - Textual Analysis for Postgres Information Retrieval 이었습니다. 우리는 여전히 tapir를 마스코트로 사용하고 있으며, 이 이름은 소스 코드의 여러 곳에 등장합니다.

PostgreSQL 버전 호환성 (PostgreSQL Version Compatibility)

pg_textsearch는 PostgreSQL 17 및 18을 지원합니다.

설치 (Installation)

사전 빌드된 바이너리 (Pre-built Binaries)

Releases page에서 사전 빌드된 바이너리를 다운로드하세요.
Linux 및 macOS (amd64 및 arm64), PostgreSQL 17 및 18에서 사용 가능합니다.

소스에서 빌드 (Build from Source)

cd /tmp
git clone https://github.com/timescale/pg_textsearch
cd pg_textsearch
...

시작하기 (Getting Started)

pg_textsearch는 shared_preload_libraries를 통해 로드되어야 합니다. postgresql.conf에 다음을 추가하고 서버를 재시작하세요:

shared_preload_libraries = 'pg_textsearch'  # 필요한 경우 기존 목록에 추가

그 다음, 확장 기능을 활성화합니다 (데이터베이스당 한 번):

CREATE EXTENSION pg_textsearch;

텍스트 콘텐츠가 포함된 테이블 생성:

CREATE TABLE documents (id bigserial PRIMARY KEY, content text);
INSERT INTO documents (content) VALUES
    ('PostgreSQL is a powerful database system'),
...

text 컬럼에 pg_textsearch 인덱스 생성

CREATE INDEX docs_idx ON documents USING bm25(content) WITH (text_config='english');

쿼리하기 (Querying)

<@> 연산자를 사용하여 가장 관련성이 높은 문서 가져오기

SELECT * FROM documents
ORDER BY content <@> 'database system'
LIMIT 5;

참고: Postgres는 연산자에 대해 ASC (오름차순) 정렬 인덱스 스캔만을 지원하기 때문에, <@>는 음수 BM25 점수를 반환합니다. 점수가 낮을수록 더 잘 일치함을 의미합니다.

인덱스는 컬럼으로부터 자동으로 감지됩니다. 명시적으로 인덱스를 지정하려면 다음과 같이 작성합니다:

SELECT * FROM documents
ORDER BY content <@> to_bm25query('database system', 'docs_idx')
LIMIT 5;

지원되는 연산:

  • text <@> 'query' - 쿼리에 대한 텍스트 점수 계산 (인덱스 자동 감지)
  • text <@> bm25query - 명시적 인덱스 지정과 함께 텍스트 점수 계산

인덱스 사용 확인 (Verifying Index Usage)

EXPLAIN으로 쿼리 계획 확인:

EXPLAIN SELECT * FROM documents
ORDER BY content <@> 'database system'
LIMIT 5;

데이터셋이 작은 경우, PostgreSQL은 순차 스캔 (Sequential Scan)을 선호할 수 있습니다. 인덱스 사용을 강제하려면 다음과 같이 설정합니다:

SET enable_seqscan = off;

참고: EXPLAIN이 순차 스캔을 보여주더라도, <@>to_bm25query는 BM25 점수 산출에 필요한 코퍼스 통계 (문서 수, 평균 길이 등)를 위해 항상 인덱스를 사용합니다.

WHERE 절을 이용한 필터링 (Filtering with WHERE Clauses)

필터링이 BM25 인덱스 스캔과 상호작용하는 방식에는 두 가지가 있습니다:

**사전 필터링 (Pre-filtering)**은 점수를 매기기 전에 별도의 인덱스 (B-tree 등)를 사용하여 행의 수를 줄입니다:

-- 필터 컬럼에 인덱스 생성
CREATE INDEX ON documents (category_id);

...

**사후 필터링 (Post-filtering)**은 BM25 인덱스 스캔을 먼저 적용한 다음 결과를 필터링합니다. 자체 인덱스가 없는 컬럼은 BM25 스캔 이후에 필터링됩니다:

SELECT * FROM documents
WHERE length(content) > 100
ORDER BY content <@> 'search terms'
...

성능 고려 사항 (Performance considerations):

  • 사전 필터링 트레이드오프 (Pre-filtering tradeoff): 필터가 많은 행(예: 10만 개 이상)과 일치하는 경우, 그 모든 행의 점수를 계산하는 것은 비용이 많이 들 수 있습니다. BM25 인덱스는 모든 일치하는 문서를 계산하는 것을 피하기 위해 top-k 최적화 (ORDER BY + LIMIT)를 사용할 수 있을 때 가장 효율적입니다.

  • 사후 필터링 트레이드오프 (Post-filtering tradeoff): 인덱스는 필터링을 수행하기 에 top-k 결과를 반환합니다. 만약 WHERE 절이 대부분의 결과를 제거한다면, 요청한 것보다 적은 행을 받게 될 수 있습니다. 이를 보완하기 위해 LIMIT을 늘린 다음, 애플리케ชัน 코드에서 다시 제한하십시오.

  • 최상의 사례 (Best case): 선택도가 높은 조건(행의 10% 미만과 일치)으로 사전 필터링을 수행한 다음, ORDER BY + LIMIT을 사용하여 BM25가 축소된 집합의 점수를 계산하도록 합니다.

이는 근사 인덱스(approximate indexes) 또한 인덱스 스캔 후에 필터링을 적용하는 pgvector의 필터링 동작과 유사합니다.

인덱싱 (Indexing)

텍스트 컬럼에 BM25 인덱스를 생성합니다:

CREATE INDEX ON documents USING bm25(content) WITH (text_config='english');

인덱스 옵션 (Index Options)

  • text_config - 사용할 PostgreSQL 텍스트 검색 설정 (필수)
  • k1 - 용어 빈도 포화 매개변수 (기본값 1.2)
  • b - 길이 정규화 매개변수 (기본값 0.75)
CREATE INDEX ON documents USING bm25(content) WITH (text_config='english', k1=1.5, b=0.8);

또한 다양한 텍스트 검색 설정을 지원합니다:

-- 어간 추출 (stemming)이 적용된 영어 문서
CREATE INDEX docs_en_idx ON documents USING bm25(content) WITH (text_config='english');

...

표현식 인덱스 (Expression Indexes)

일반 컬럼 대신 표현식을 인덱싱합니다 — JSONB 필드, 다중 컬럼 결합, 텍스트 변환에 유용합니다:

-- JSONB 필드 추출
CREATE INDEX ON events USING bm25 ((data->>'description'))
    WITH (text_config='english');
...

표현식은 text로 평가되어야 하며 IMMUTABLE 함수만 사용해야 합니다. 쿼리의 ORDER BY 절에서도 동일한 표현식을 반복해야 합니다.

부분 인덱스 (Partial Indexes)

WHERE 절을 추가하여 행의 일부 서브셋(subset)을 인덱싱합니다. 쿼리가 항상 특정 서브셋만을 대상으로 할 때, 부분 인덱스 (Partial Indexes)는 더 작고 빠릅니다.

CREATE INDEX ON docs USING bm25 (content)
    WITH (text_config='english')
    WHERE status = 'published';
...

부분 인덱스는 to_bm25query()를 통한 명시적인 인덱스 이름 지정이 필요합니다. 암시적인 text <@> 'query' 구문은 이를 건너뜁니다.

표현식 인덱스 (Expression Index)와 부분 인덱스는 결합할 수 있습니다:

CREATE INDEX ON events USING bm25 ((data->>'message'))
    WITH (text_config='english')
    WHERE (data->>'severity') = 'error';

다국어 테이블 (Multilingual Tables)

여러 언어로 된 문서가 포함된 테이블의 경우, 각 언어에 적합한 텍스트 검색 설정 (text search configuration)을 가진 개별 부분 인덱스를 생성하십시오:

ALTER TABLE docs ADD COLUMN lang CHAR(2) NOT NULL DEFAULT 'en';

CREATE INDEX docs_en_idx ON docs USING bm25 (content)
...

각 인덱스는 언어에 적합한 어간 추출 (stemming) 및 불용어 (stop words)를 적용합니다. 일치하는 술어 (predicate)와 인덱스 이름을 사용하여 쿼리하십시오:

SELECT * FROM docs
WHERE lang = 'en'
ORDER BY content <@> to_bm25query('databases', 'docs_en_idx')
...

데이터 타입 (Data Types)

bm25query

bm25query 타입은 선택적인 인덱스 컨텍스트 (index context)를 포함하여 BM25 점수 산정 (scoring)을 위한 쿼리를 나타냅니다:

-- 인덱스 이름을 포함한 bm25query 생성 (WHERE 절 및 단독 점수 산정을 위해 필요)
SELECT to_bm25query('search query text', 'docs_idx');
-- 반환값: docs_idx:search query text
...

참고: PostgreSQL 18에서는 단일 콜론(:)을 사용하는 임베디드 인덱스 이름 구문을 통해, 쿼리 플래너 (query planner)가 SELECT 절의 표현식을 조기에 평가할 때도 인덱스 이름을 결정할 수 있도록 합니다. 이는 다양한 쿼리 평가 전략 간의 호환성을 보장합니다.

bm25query 함수 (bm25query Functions)

함수설명
to_bm25query(text) → bm25query인덱스 이름 없이 bm25query 생성 (ORDER BY 전용)
to_bm25query(text, text) → bm25query쿼리 텍스트와 인덱스 이름으로 bm25query 생성
text <@> bm25query → double precisionBM25 스코어링 연산자 (음수 점수 반환)
bm25query = bm25query → boolean동등 비교

성능 (Performance)

pg_textsearch 인덱스는 효율적인 쓰기 작업을 위해 디스크 기반의 페이지드 멤테이블 (on-disk paged memtable, LSM의 L0 단계)을 사용합니다. 멤테이블 (memtable)은 표준 버퍼 잠금 (buffer locks) 하에서 변경되며 GenericXLog를 통해 WAL (Write-Ahead Logging)에 기록됩니다. 다른 인덱스 유형과 마찬가지로, 데이터를 모두 로드한 후에 인덱스를 생성하는 것이 더 빠릅니다.

-- 데이터를 먼저 로드합니다
INSERT INTO documents (content) VALUES (...);

...

병렬 인덱스 빌드 (Parallel Index Builds)

pg_textsearch는 대규모 테이블의 빠른 인덱싱을 위해 병렬 인덱스 빌드 (parallel index builds)를 지원합니다. Postgres는 테이블 크기와 설정에 따라 자동으로 병렬 워커 (parallel workers)를 사용합니다.

-- 병렬 워커 설정 (선택 사항, 설정하지 않으면 서버 기본값 사용)
SET max_parallel_maintenance_workers = 4;
SET maintenance_work_mem = '256MB';  -- 병렬 빌드를 위해 최소 64MB 필요
...

참고: 플래너 (planner)가 병렬 인덱스 빌드를 활성화하려면 maintenance_work_mem >= 64MB가 필요합니다. 메모리가 부족하면 빌드는 조용히 직렬 모드 (serial mode)로 전환됩니다.

병렬 빌드가 사용될 때 다음과 같은 알림이 표시됩니다:

NOTICE:  parallel index build: launched 4 of 4 requested workers

파티션 테이블 (partitioned tables)의 경우, 각 파티션의 크기가 충분히 크다면 각 파티션은 병렬 워커를 사용하여 독립적으로 인덱스를 빌드합니다. 이를 통해 매우 큰 파티션 데이터셋을 효율적으로 인덱싱할 수 있습니다.

성능 튜닝 (Performance Tuning)

세그먼트 강제 병합 (Force-merging segments)

인덱스는 여러 레벨에 걸쳐 여러 세그먼트 (segments)에 데이터를 저장합니다 (LSM 트리와 유사). 대량 로드 (bulk loads) 또는 지속적인 증분 삽입 (incremental inserts) 이후에는 여러 세그먼트가 쌓일 수 있습니다. 이를 하나로 통합하면 스캔해야 하는 세그먼트 수가 줄어들어 쿼리 속도가 향상됩니다:

SELECT bm25_force_merge('docs_idx');

이는 Lucene의 forceMerge(1)와 유사합니다. 모든 세그먼트 (segment)를 단일 세그먼트로 다시 작성하고, 해제된 페이지를 회수합니다. 대규모 배치 삽입 (batch insert) 후에 사용하는 것이 가장 좋으며, 지속적인 쓰기 트래픽이 발생하는 동안에는 권장되지 않습니다.

ORDER BY와 함께 LIMIT 사용하기

Top-k 쿼리 (ORDER BY ... LIMIT n)를 사용하면 Block-Max WAND 최적화를 활성화할 수 있으며, 이를 통해 상위 결과에 기여할 수 없는 포스팅 (postings) 블록들을 건너뛸 수 있습니다. LIMIT 절이 없으면, 인덱스는 pg_textsearch.default_limit까지 모든 일치하는 문서의 점수를 매기는 방식으로 되돌아갑니다.

-- 빠름: BMW가 경쟁력이 없는 블록을 건너뜀
SELECT * FROM documents ORDER BY content <@> 'search terms' LIMIT 10;

...

세그먼트 압축 (Segment compression)

압축은 기본적으로 활성화되어 있으며, 일반적으로 인덱스 크기와 쿼리 성능(읽어야 할 페이지 수 감소)을 모두 향상시킵니다. 압축 해제 오버헤드가 워크로드의 병목 현상이 되는 경우에만 비활성화하십시오:

SET pg_textsearch.compress_segments = off;

인덱스 빌드에 영향을 미치는 Postgres 설정

설정효과
max_parallel_maintenance_workersCREATE INDEX를 위한 병렬 워커 (parallel worker) 수 (기본값 2)
maintenance_work_mem워커당 메모리; 병렬 빌드를 위해서는 64MB 이상이어야 함

pg_textsearch GUCs

설정기본값설명
pg_textsearch.default_limit1000LIMIT 절이 없을 때 점수를 매기는 최대 문서 수
pg_textsearch.compress_segmentson새 세그먼트의 포스팅 블록을 압축
pg_textsearch.segments_per_level8자동 압축 (automatic compaction) 전 레벨당 세그먼트 수 (2-64)
pg_textsearch.bulk_load_threshold100000자동 스필 (auto-spill) 전 트랜잭션당 용어 (terms) 수 (0 = 비활성화)
pg_textsearch.memtable_pages_threshold64자동 스필 전 체인 페이지 (chain pages) 수 (0 = 비활성화)

Memtable 아키텍처

1.3.0 버전부터 L0 memtable (메모리 테이블)은 표준 버퍼 잠금 (buffer locks) 하에서 변경되고 GenericXLog를 통해 WAL (Write-Ahead Logging)에 기록되는 문서-레코드 페이지 (doc-record pages) 체인으로서 인덱스 관계 (index relation) 자체에 존재합니다. 공유 메모리 memtable, 커스텀 WAL 리소스 매니저, 또는 docid-page 복구 스캐폴드 (scaffold)는 존재하지 않습니다. PostgreSQL의 기본 WAL 재생 (online-page-fix 도구에서 사용되는 단일 페이지 재구성 헬퍼 포함)은 pg_textsearch.so를 로드할 필요 없이 모든 페이지를 재구성합니다. 사양은 docs/memtable_v2.md를 참조하십시오.

자동 스필 (Auto-spill)은 두 가지 상호 보완적인 트리거에 의해 제어됩니다:

  • memtable_pages_threshold — 체인이 설정된 페이지 수를 초과하여 성장할 때마다 각 삽입 (insert) 후에 발생합니다. 기본값인 64 페이지 (8 KB 블록 기준 약 512 KB)는 체인을 작게 유지하여 쿼리 지연 시간 (query latency)을 제한된 범위 내로 유지합니다.
  • bulk_load_threshold — 단일 트랜잭션이 memtable에 많은 용어 (terms)를 축적할 때 COMMIT 시점에 발생합니다. 이는 COPY / 대량 INSERT (bulk INSERT) 시 체인 페이지 (chain-page)의 성장을 제한하는 데 유용합니다.
-- 수동 스필 (현재 체인을 새로운 L0 세그먼트로 강제 이동)
SELECT bm25_spill_index('docs_idx');

VACUUM (autovacuum의 삽입 임계값 경로 포함) 또한 실행될 때 memtable을 스필하므로, CREATE INDEX와 다음 서버 재시작 사이의 스필되지 않은 상태 (un-spilled state)의 양은 제한된 상태로 유지됩니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 HN OpenAI Codex의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0