Skip to content

This is the multi-page printable view of this section. .

Return to the regular view of this page.

vchord_bm25 & pg_tokenizer

Native BM25 ranking index and Block-WeakAnd full-text search for PostgreSQL (vchord_bm25 & pg_tokenizer).

vchord_bm25 (maintained by Pigsty at pgsty/vchord_bm25) is a native PostgreSQL extension built in Rust (pgrx) that implements the Okapi BM25 ranking algorithm and the Block-WeakAnd (Block-Max WAND) Top-K index scan access method inside PostgreSQL.

Paired with pg_tokenizer (maintained by Pigsty at pgsty/pg_tokenizer for customizable multilingual tokenization and vocabulary models) or PostgreSQL’s built-in tsvector analyzer, vchord_bm25 brings Elasticsearch/Lucene-class lexical full-text ranking directly into your relational database—and combines seamlessly with vchord for hybrid semantic + lexical search.

Understand why BM25 matters, install vchord_bm25 and pg_tokenizer, and run your first ranked full-text query.

Build bm25 indexes, run Top-K queries, apply WHERE prefilters, and fuse BM25 with VectorChord dense vectors.

Configure text analyzers, stemmers, stopwords, synonyms, HuggingFace models, Chinese Jieba, and Japanese Lindera.

Complete SQL types, operators, functions, GUC parameters, version evolution, and BEIR benchmark results.

Highlights

  • True Okapi BM25 Scoring: Supports inverse document frequency (IDF), term frequency saturation (k1k_1), and quantized 1-byte document length normalization (bb and average document length avgdl\text{avgdl})—solving fundamental relevance limitations of PostgreSQL’s native ts_rank.
  • Block-WeakAnd Index Acceleration: Stores compressed posting lists with per-block maximum score upper bounds using delta-bitpacking, skipping non-competitive document blocks during Top-K queries without scanning entire inverted lists.
  • Multilingual Tokenization: Works with pg_tokenizer (llmlingua2, bert_base_uncased, wiki_tocken, gemma2b, custom corpus-trained models, Chinese jieba, Japanese lindera) in v0.1–v0.3, and supports PostgreSQL’s native tsvector directly in the next-generation main architecture.
  • Native Hybrid Search with VectorChord: Run lexical BM25 (vchord_bm25) and dense vector search (vchordrq) inside a single SQL query using Reciprocal Rank Fusion (RRF) or ColBERT MaxSim reranking.

1 - Getting Started

Overview, installation, and quick start guide for vchord_bm25 and pg_tokenizer.

Get started with vchord_bm25 in PostgreSQL:

Why BM25 + Block-WeakAnd outperforms native PostgreSQL ts_rank and GIN indexes.

Install vchord_bm25 and pg_tokenizer via Pigsty, Docker, Debian packages, or source.

Tokenize text, build a bm25 index, and run Top-K ranked full-text queries in minutes.

1.1 - Overview

Why VectorChord-BM25 brings native Okapi BM25 and Block-WeakAnd Top-K search to PostgreSQL.

PostgreSQL includes built-in full-text search via tsvector, tsquery, ts_rank, and GIN indexes. While effective for boolean keyword matching (@@), PostgreSQL’s built-in ranking has two structural limitations for modern search and Retrieval-Augmented Generation (RAG) workloads:

  1. Suboptimal Ranking Formula (ts_rank): PostgreSQL’s ts_rank and ts_rank_cd do not track corpus-wide Inverse Document Frequency (IDF) or Average Document Length (avgdl\text{avgdl}) across the index. Common words are weighted the same as rare, high-signal terms, and term frequency is not properly saturated.
  2. Exhaustive Index Scans on ORDER BY ... LIMIT K: A PostgreSQL GIN index only accelerates boolean matching (WHERE tsv @@ tsquery). To return the Top-10 highest-ranked documents, PostgreSQL must fetch every matching row from the heap, compute ts_rank for all of them, and sort the entire result set.

How VectorChord-BM25 Solves Both Problems

VectorChord-BM25 (vchord_bm25) introduces a dedicated PostgreSQL index access method (USING bm25) and sparse BM25 scoring operator (<&>) specifically engineered for Top-K lexical retrieval:

Capability PostgreSQL Built-in (GIN + ts_rank) VectorChord-BM25 (vchord_bm25)
Ranking Model Local term frequency / proximity (ts_rank) Corpus-wide Okapi BM25 (IDF\text{IDF}, k1k_1, bb, avgdl\text{avgdl})
Top-K Execution Fetch & score all matching tuples, then sort Block-WeakAnd (Block-Max WAND) early pruning inside the index
Posting Compression Uncompressed or basic varint TID lists SIMD-friendly Delta-Bitpacking in 128-doc blocks
Document Length Norm Optional crude bitmask flags 1-byte Fieldnorm Quantization (256-entry lookup table)
Query Semantics Boolean AND/OR match required Non-conjunctive Top-K ranking across all query terms
Tokenization Built-in ts_debug / Snowball pg_tokenizer (LLM tokenizers, jieba, lindera, custom vocab) or native tsvector

Architecture Overview

flowchart LR
    subgraph Tokenization["1. Tokenization Layer"]
        T1["Raw Text"] --> T2["pg_tokenizer (v0.1–v0.3)\\nor to_tsvector (main)"]
        T2 --> T3["bm25vector / tsvector\\n(Token IDs + Term Frequencies)"]
    end
    subgraph Index["2. BM25 Index (USING bm25)"]
        I1["Growing Segment\\n(Append-only pages)"] --> I2["Sealed Segment\\n(Delta-Bitpacked 128-doc blocks)"]
        I2 --> I3["Term Statistics (DF)\\n+ 1-Byte Fieldnorm Array"]
    end
    subgraph Query["3. Top-K Query (ORDER BY vec <&> bm25query LIMIT k)"]
        Q1["Block-Max Score Upper Bounds"] --> Q2["Block-WeakAnd Pivot Pruning"]
        Q2 --> Q3["Top-K Heap (-BM25 Score)"]
    end
    T3 --> Index
    Index --> Query

Two API Generations

When working with vchord_bm25, be aware of the two API generations across releases:

  1. Stable Releases (v0.1.0 – v0.3.0, schema bm25_catalog):

    • Uses custom data types bm25vector (sparse token_id:frequency map, e.g., {1:1, 2:2}) and bm25query.
    • Pairs with the pg_tokenizer extension (tokenizer_catalog), which converts text into INT[] token ID arrays that cast implicitly to bm25vector.
    • Index creation: CREATE INDEX idx ON documents USING bm25 (embedding bm25_ops);
    • Query construction: to_bm25query('idx', tokenize('query text', 'tokenizer_name'))
  2. Next-Gen Native tsvector Architecture (main branch):

    • Operates directly on PostgreSQL’s built-in tsvector data type (CREATE OPERATOR CLASS bm25_ops FOR TYPE tsvector USING bm25), removing the requirement for a separate bm25vector column.
    • Index creation: CREATE INDEX idx ON documents USING bm25 (vector bm25_ops) WITH (options = $$ k1 = 1.2, b = 0.75 $$);
    • Query construction: to_bm25query(to_tsvector('english', 'query text'), 'idx')
Tip

Both workflows are documented side-by-side throughout this guide so you can use whichever version matches your deployment.

1.2 - Installation

Install VectorChord-BM25 (vchord_bm25) and pg_tokenizer via Pigsty, Docker, Debian packages, or source.

vchord_bm25 supports PostgreSQL 14, 15, 16, 17, and 18 on Linux (x86_64 and aarch64) and macOS.

Pigsty provides pre-built native RPM and DEB packages for vchord, vchord_bm25, and pg_tokenizer across EL 8/9/10, Debian 12/13, and Ubuntu 22.04/24.04 for PostgreSQL 14–18.

# Install via Pigsty CLI (automatically resolves PG version and OS distro)
pig ext install vchord_bm25
pig ext install pg_tokenizer

Or install directly via apt / dnf once the Pigsty repository is configured:

# Debian / Ubuntu (replace 17 with your PostgreSQL major version)
sudo apt-get install postgresql-17-vchord-bm25 postgresql-17-pg-tokenizer

# RHEL / Rocky / AlmaLinux (replace 17 with your PostgreSQL major version)
sudo dnf install vchord_bm25_17 pg_tokenizer_17

Method 2: Docker (tensorchord/vchord-suite)

The official tensorchord/vchord-suite Docker image bundles PostgreSQL, VectorChord (vchord), VectorChord-BM25 (vchord_bm25), and pg_tokenizer out of the box:

docker run \\
  --name vchord-suite \\
  -e POSTGRES_PASSWORD=postgres \\
  -p 5432:5432 \\
  -d tensorchord/vchord-suite:pg18-latest
Note

You can also pull from GitHub Container Registry using ghcr.io/tensorchord/vchord-suite:pg18-latest (or pg17-latest, pg16-latest, pg15-latest, pg14-latest).

Connect with psql:

psql -h localhost -p 5432 -U postgres

Method 3: GitHub Release Packages (.deb & RHEL .zip)

Download prebuilt .deb or RHEL 9+ .zip packages for your PostgreSQL major version (14–18) and CPU architecture (amd64 / x86_64 or arm64 / aarch64) from the GitHub Releases pages of pgsty/vchord_bm25 (upstream supervc-stack/VectorChord-bm25) and pgsty/pg_tokenizer (upstream supervc-stack/pg_tokenizer.rs):

Debian / Ubuntu (.deb)

# Install vchord_bm25 (example for PostgreSQL 16 on x86_64)
curl -LO https://github.com/supervc-stack/VectorChord-bm25/releases/download/0.3.0/postgresql-16-vchord-bm25_0.3.0-1_amd64.deb
sudo apt-get install ./postgresql-16-vchord-bm25_0.3.0-1_amd64.deb

# Install pg_tokenizer (example for PostgreSQL 16 on x86_64)
curl -LO https://github.com/supervc-stack/pg_tokenizer.rs/releases/download/0.1.1/postgresql-16-pg-tokenizer_0.1.1-1_amd64.deb
sudo apt-get install ./postgresql-16-pg-tokenizer_0.1.1-1_amd64.deb

RHEL 9+ / Enterprise Linux (.zip Archive)

For RHEL 9+ systems when installing manually from the release .zip archive (requires glibc >= 2.34):

# 1. Locate PostgreSQL library and extension directories
pg_config --pkglibdir
# Example: /usr/pgsql-16/lib
pg_config --sharedir
# Example: /usr/pgsql-16/share

# 2. Download and extract the release archive
wget https://github.com/supervc-stack/pg_tokenizer.rs/releases/download/0.1.1/pg16-pg_tokenizer-0.1.1-x86_64-unknown-linux-gnu.zip
unzip pg16-pg_tokenizer-0.1.1-x86_64-unknown-linux-gnu.zip

# 3. Copy the shared library (.so) and extension control/SQL files
sudo cp ./usr/lib/postgresql/16/lib/pg_tokenizer.so $(pg_config --pkglibdir)
sudo cp ./usr/share/postgresql/16/extension/* $(pg_config --sharedir)/extension

Method 4: Build from Source (cargo-pgrx)

To compile pgsty/vchord_bm25 and pgsty/pg_tokenizer from source:

# 1. Install Rust toolchain and system dependencies (clang, libclang, pkg-config, PostgreSQL dev headers)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 2. Build and install vchord_bm25
git clone https://github.com/pgsty/vchord_bm25.git
cd vchord_bm25
cargo install cargo-pgrx --version $(grep -o 'pgrx = "=[^"]*' Cargo.toml | cut -d = -f 3)
cargo pgrx init --pg17=$(which pg_config)
cargo pgrx install --release --pg-config $(which pg_config)

# 3. Build and install pg_tokenizer
git clone https://github.com/pgsty/pg_tokenizer.git
cd pg_tokenizer
cargo pgrx install --release --pg-config $(which pg_config)
Tip

Optional Lindera Dictionaries at Compile Time: To embed Lindera CJK morphological dictionaries (ipadic, ipadic-neologd, unidic, ko-dic, cc-cedict) directly into pg_tokenizer.so, pass the --features flag when compiling pg_tokenizer:

cargo pgrx install --release --pg-config $(which pg_config) \
  --features "lindera-ipadic lindera-ipadic-neologd lindera-unidic lindera-ko-dic lindera-cc-cedict"

Development & Debugging (cargo-pgrx)

When developing or debugging vchord_bm25 or pg_tokenizer locally:

# Initialize pgrx managed PostgreSQL instances (PG 14–18)
cargo pgrx init

# Use the `dev-opt` profile to enable optimizations alongside debug symbols
RUST_BACKTRACE=1 cargo pgrx run --profile dev-opt

# Inside the interactive psql session, inspect the backend PID to attach gdb if needed:
SELECT pg_backend_pid();

Configure PostgreSQL & Enable Extensions

If you use pg_tokenizer and want preloaded vocabulary models initialized at server startup, add pg_tokenizer (and vchord if used) to shared_preload_libraries in postgresql.conf:

shared_preload_libraries = 'vchord.so, pg_tokenizer.so'

Restart PostgreSQL after modifying shared_preload_libraries. (vchord_bm25 itself does not require shared_preload_libraries and can be loaded dynamically per session via CREATE EXTENSION.)

2. Create Extensions and Set search_path

Connect to your target database and enable the extensions:

CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;

In v0.1.0–v0.3.0, vchord_bm25 installs its objects into the bm25_catalog schema and pg_tokenizer installs into tokenizer_catalog. Add both schemas to your search_path:

-- Per-session
SET search_path TO "$user", public, bm25_catalog, tokenizer_catalog;

-- Or persist for your database / role
ALTER DATABASE postgres SET search_path TO "$user", public, bm25_catalog, tokenizer_catalog;

Verify the installed version:

SELECT extname, extversion FROM pg_extension WHERE extname IN ('vchord_bm25', 'pg_tokenizer');

1.3 - Quick Start

Run your first BM25 ranked full-text search query in PostgreSQL with vchord_bm25.

This guide walks through creating a table, tokenizing documents, building a bm25 index, and executing Top-K BM25 queries.

Workflow A: bm25vector + pg_tokenizer (v0.1 – v0.3)

In released versions v0.1.0 through v0.3.0, vchord_bm25 pairs with pg_tokenizer to tokenize text into a bm25vector column.

1. Enable Extensions and Configure search_path

CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;

SET search_path TO "$user", public, bm25_catalog, tokenizer_catalog;

2. Create a Tokenizer

Use create_tokenizer with the built-in llmlingua2 (or bert_base_uncased) model:

SELECT create_tokenizer('my_tokenizer', $$
model = "llmlingua2"
$$);
Tip

Need domain-specific vocabulary, Chinese (jieba), or Japanese (lindera)? See the Tokenizers (pg_tokenizer) section.

3. Create a Table and Populate Documents

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    passage TEXT NOT NULL,
    embedding bm25vector
);

INSERT INTO documents (passage) VALUES
  ('PostgreSQL is a powerful, open-source object-relational database system. It has over 35 years of active development.'),
  ('Full-text search is a technique for searching a single computer-stored document or a collection in a full-text database.'),
  ('BM25 is a bag-of-words retrieval function that ranks a set of documents based on the query terms appearing in each document.'),
  ('VectorChord-bm25 is a PostgreSQL extension that provides native BM25 ranking index using the Block-WeakAnd algorithm.');

4. Tokenize Documents into bm25vector

tokenize(text, tokenizer_name) returns an INT[] array of token IDs, which implicitly casts to bm25vector (aggregating duplicate token IDs into term frequencies):

UPDATE documents
SET embedding = tokenize(passage, 'my_tokenizer');

-- Inspect the sparse bm25vector representation ({token_id:term_frequency, ...})
SELECT id, embedding FROM documents LIMIT 2;

5. Build the bm25 Index

CREATE INDEX documents_embedding_bm25
ON documents
USING bm25 (embedding bm25_ops);

6. Run Top-K BM25 Queries

Use the <&> operator with to_bm25query(index_name, query_vector) in an ascending ORDER BY clause:

SELECT
    id,
    passage,
    -(embedding <&> to_bm25query('documents_embedding_bm25', tokenize('PostgreSQL BM25 index', 'my_tokenizer'))) AS bm25_score
FROM documents
ORDER BY embedding <&> to_bm25query('documents_embedding_bm25', tokenize('PostgreSQL BM25 index', 'my_tokenizer'))
LIMIT 10;
Important

Why is <&> negative? PostgreSQL only supports index-driven ORDER BY scans in ascending (ASC) order. Therefore, <&> returns the negative BM25 score (e.g., -2.8415). More negative values represent higher relevance, allowing ORDER BY embedding <&> to_bm25query(...) ASC LIMIT K to use the Block-WeakAnd index scan directly. Negate the expression (-(embedding <&> ...)) in SELECT if you want positive scores displayed.


Workflow B: Native tsvector API (main Branch)

In the next-generation main branch rewrite, vchord_bm25 indexes PostgreSQL’s native tsvector type directly—no external tokenizer extension is required for standard languages.

1. Create Extension and Table

CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    passage TEXT NOT NULL,
    vector tsvector GENERATED ALWAYS AS (to_tsvector('english', passage)) STORED
);

INSERT INTO documents (passage) VALUES
  ('PostgreSQL is a powerful, open-source object-relational database system.'),
  ('Full-text search is a technique for searching documents in a full-text database.'),
  ('BM25 is a bag-of-words retrieval function that ranks documents based on query terms.'),
  ('VectorChord-bm25 provides a native BM25 ranking index in PostgreSQL.');

2. Build the bm25 Index on tsvector

CREATE INDEX documents_bm25_idx
ON documents
USING bm25 (vector bm25_ops)
WITH (options = $$
k1 = 1.2
b = 0.75
$$);

3. Query with to_bm25query(tsvector, regclass)

SELECT
    id,
    passage,
    -(vector <&> to_bm25query(to_tsvector('english', 'PostgreSQL BM25 index'), 'documents_bm25_idx')) AS bm25_score
FROM documents
ORDER BY vector <&> to_bm25query(to_tsvector('english', 'PostgreSQL BM25 index'), 'documents_bm25_idx')
LIMIT 10;

2 - Usage & Hybrid Search

Index management, Top-K querying, prefiltering, hybrid search with vchord, and Block-WeakAnd internals.

Learn how to build, tune, and query vchord_bm25 indexes in production:

Create bm25 indexes, tune segment sizes and BM25 parameters (k1, b), monitor parallel builds, and inspect pages.

Run Top-K Block-WeakAnd queries, understand negative BM25 scores, and tune bm25_limit.

2.1 - Indexing & Maintenance

Create, configure, maintain, and inspect bm25 indexes in PostgreSQL.

vchord_bm25 provides the bm25 index access method (CREATE ACCESS METHOD bm25 TYPE INDEX HANDLER _bm25_amhandler) and the bm25_ops operator class.

Creating a bm25 Index

Stable Releases (v0.1 – v0.3, bm25vector)

CREATE INDEX documents_embedding_bm25
ON documents
USING bm25 (embedding bm25_ops);

Next-Gen main Branch (tsvector)

In the main branch, bm25_ops operates on tsvector and supports index-level storage parameters (options, limit, and prefilter):

CREATE INDEX documents_vector_bm25
ON documents
USING bm25 (vector bm25_ops)
WITH (
    options = $$
    k1 = 1.2
    b = 0.75
    $$,
    limit = 100,
    prefilter = false
);
Storage Parameter (WITH (...)) Type Default Range Description
options (k1) float (in TOML) 1.2 1.2 – 2.0 Term frequency saturation parameter k1k_1. Higher values increase the weight of repeated occurrences of a term within a document.
options (b) float (in TOML) 0.75 0.0 – 1.0 Document length normalization parameter bb. 0.0 disables length normalization; 1.0 fully normalizes by document length relative to avgdl\text{avgdl}.
limit int 0 0 – 65535 Default Top-K search limit stored on the index (used when session GUC bm25.limit = 0).
prefilter bool false true / false Default prefilter behavior stored on the index (can be overridden per session via bm25.prefilter).

You can dynamically update limit or prefilter on an existing index with ALTER INDEX:

ALTER INDEX documents_vector_bm25 SET (limit = 200, prefilter = true);
ALTER INDEX documents_vector_bm25 RESET (limit, prefilter);

Automatic Tokenization Triggers (v0.1 – v0.3)

When using pg_tokenizer, you can keep your bm25vector column automatically synchronized on INSERT and UPDATE:

Using a Custom Model Trigger (One-Step Helper)

create_custom_model_tokenizer_and_trigger creates a custom model, a tokenizer, backfills existing rows, and attaches an automatic BEFORE INSERT OR UPDATE trigger:

SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name => 'my_tokenizer',
    model_name     => 'my_model',
    text_analyzer_name => 'my_analyzer',
    table_name     => 'documents',
    source_column  => 'passage',
    target_column  => 'embedding'
);

Using a Standard PL/pgSQL Trigger with Pretrained Models

If you use a pretrained model (such as llmlingua2 or bert_base_uncased), create a standard trigger function:

CREATE OR REPLACE FUNCTION documents_tokenize_trigger()
RETURNS trigger AS $$
BEGIN
    NEW.embedding := tokenize(NEW.passage, 'my_tokenizer');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_documents_tokenize
BEFORE INSERT OR UPDATE OF passage ON documents
FOR EACH ROW
EXECUTE FUNCTION documents_tokenize_trigger();

Segment Architecture: Growing vs. Sealed Segments (v0.1 – v0.3)

Internally, vchord_bm25 organizes index tuples into two segment states:

  1. Growing Segment: Newly inserted tuples (INSERT / UPDATE) are appended to the growing segment for fast write throughput without re-compressing entire posting lists.
  2. Sealed Segment: When the growing segment exceeds bm25_catalog.segment_growing_max_page_size pages (default 4096 pages, ~32 MB), it is sealed into an immutable, read-only segment with delta-bitpacked 128-document blocks, precomputed block-max upper bounds, and term document frequency statistics.
-- Adjust the growing segment seal threshold (in 8 KB pages, range 1..1000000)
SET bm25_catalog.segment_growing_max_page_size = 4096;

Parallel Index Build & Progress Monitoring

Index creation supports PostgreSQL parallel maintenance workers and reports live build progress via pg_stat_progress_create_index across three phases:

  1. initializing
  2. scanning table
  3. writing index structures
-- Speed up large index builds with parallel workers
SET max_parallel_maintenance_workers = 8;
SET maintenance_work_mem = '2GB';

-- Monitor build progress from another session
SELECT
    pid,
    phase,
    tuples_total,
    tuples_done,
    round(100.0 * tuples_done / nullif(tuples_total, 0), 2) AS pct_done
FROM pg_stat_progress_create_index;

Inspecting Index Pages (v0.3.0)

Version 0.3.0 provides bm25_page_inspect(index regclass, blkno int) to inspect the internal metadata and page headers of a bm25 index:

-- Inspect the metapage (block 0) of a bm25 index
SELECT bm25_page_inspect('documents_embedding_bm25', 0);

Index Constraints & Limitations

  • Single-Column Only: A bm25 index can only be created on a single bm25vector (or tsvector) column. Multicolumn bm25 indexes are not supported.
  • No Unlogged Tables: UNLOGGED tables are not supported by the bm25 access method (ambuildempty raises Unlogged indexes are not supported).
  • Forward Scan Direction Only: bm25 index scans only support forward (ASC) scan direction on <&>.

2.2 - Querying & Scoring

Execute Top-K BM25 queries, interpret negative BM25 scores, and tune search limits.

Basic Top-K Query Syntax

To run an index-accelerated Top-K BM25 search, use the <&> operator in the ORDER BY clause together with to_bm25query:

-- v0.1 – v0.3 (bm25vector + pg_tokenizer)
SELECT
    id,
    passage,
    embedding <&> to_bm25query('documents_embedding_bm25', tokenize('PostgreSQL vector search', 'my_tokenizer')) AS raw_score,
    -(embedding <&> to_bm25query('documents_embedding_bm25', tokenize('PostgreSQL vector search', 'my_tokenizer'))) AS bm25_score
FROM documents
ORDER BY raw_score ASC
LIMIT 10;
-- main branch (native tsvector)
SELECT
    id,
    passage,
    -(vector <&> to_bm25query(to_tsvector('english', 'PostgreSQL vector search'), 'documents_bm25_idx')) AS bm25_score
FROM documents
ORDER BY vector <&> to_bm25query(to_tsvector('english', 'PostgreSQL vector search'), 'documents_bm25_idx') ASC
LIMIT 10;

Why to_bm25query Requires the Index Name

Unlike vector distance operators (where the distance between two vectors depends only on the two vectors themselves), the BM25 score of a document depends on corpus-wide statistics stored inside the BM25 index:

  • Total number of documents in the corpus (NN)
  • Average document length across the corpus (avgdl\text{avgdl})
  • Document frequency of each query token (n(qi)n(q_i))

Passing the index ('documents_embedding_bm25'::regclass) into to_bm25query(...) allows the standalone scoring function (search_bm25query / _bm25_evaluate) to read corpus statistics from the index even when PostgreSQL evaluates <&> in the SELECT projection list or during a brute-force sequential scan.

Understanding Negative BM25 Scores

By design, <&> returns negative BM25 scores:

scoreoperator(D,Q)=−BM25(D,Q)≤0\text{score}_{\text{operator}}(D, Q) = -\text{BM25}(D, Q) \le 0
  • PostgreSQL’s index access method API (amcanorderbyop) only supports ORDER BY <operator> ASC for index-ordered scans.
  • Because higher BM25 scores mean better relevance, returning $-\text{BM25}(D, Q)$ places the most relevant documents first when sorted in default ascending (ASC) order.
  • Never use ORDER BY embedding <&> to_bm25query(...) DESC—PostgreSQL cannot use the forward bm25 index scan for DESC ordering and will either fall back to a full scan or raise a scan direction error.

Tuning bm25_limit vs SQL LIMIT

Because Block-WeakAnd maintains a bounded Top-K min-heap during the index scan to aggressively prune non-competitive posting blocks, the index scanner needs to know KK (the maximum number of candidates to collect) when the scan begins.

In v0.1.0 – v0.3.0: bm25_catalog.bm25_limit

The GUC bm25_catalog.bm25_limit (default 100, range -1 to 65535) controls the maximum number of Top-K documents returned by the bm25 index scan:

-- Default is 100. Increase it if your SQL query requests LIMIT 500:
SET bm25_catalog.bm25_limit = 500;

SELECT id, passage
FROM documents
ORDER BY embedding <&> to_bm25query('documents_embedding_bm25', tokenize('database', 'my_tokenizer'))
LIMIT 500;

Special values of bm25_catalog.bm25_limit:

Value Behavior
1 – 65535 Uses Block-WeakAnd to compute and return up to bm25_limit top-scoring documents (default 100).
0 Immediately returns 0 rows.
-1 Disables Block-WeakAnd Top-K pruning and performs a full brute-force scan over all matching postings in the index.

In main: bm25.limit and Index limit Reloption

In the main branch, the effective limit is determined in priority order:

  1. Session GUC bm25.limit (if > 0)
  2. Index storage parameter WITH (limit = ...) (if > 0)
SET bm25.limit = 100;
Warning

Always ensure bm25_catalog.bm25_limit (or bm25.limit) is greater than or equal to the LIMIT in your SQL query. If your SQL query specifies LIMIT 200 while bm25_limit is 100, the index scan will stop after yielding 100 rows.

Verifying the Query Plan with EXPLAIN

Use EXPLAIN (ANALYZE, BUFFERS) to confirm that PostgreSQL is using Index Scan using ... on documents:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM documents
ORDER BY embedding <&> to_bm25query('documents_embedding_bm25', tokenize('PostgreSQL', 'my_tokenizer'))
LIMIT 10;

To force a sequential scan + exact brute-force evaluation (useful for verifying recall or debugging), disable the index scan temporarily:

-- v0.1 – v0.3
SET bm25_catalog.enable_index = false;

-- main branch
SET bm25.enable_scan = false;

2.3 - Filtered Search & Prefiltering

Combine SQL WHERE clauses with BM25 Top-K ranking using post-filtering, pre-filtering, and partial indexes.

Real-world applications frequently combine full-text BM25 ranking with structured relational filters (such as category_id = 5, tenant_id = 'acme', or date ranges). vchord_bm25 supports three strategies for filtered BM25 search.

1. Post-Filtering vs. Pre-Filtering

When you execute a query with a WHERE clause and ORDER BY embedding <&> to_bm25query(...) LIMIT K:

SELECT id, passage
FROM documents
WHERE category = 'database'
ORDER BY embedding <&> to_bm25query('documents_embedding_bm25', tokenize('PostgreSQL BM25', 'my_tokenizer'))
LIMIT 10;

There are two ways vchord_bm25 can evaluate the WHERE predicate:

Post-Filtering (enable_prefilter = false)

  1. The bm25 index runs Block-WeakAnd over the entire corpus and returns the global Top-bm25_limit candidates (e.g., Top 100).
  2. PostgreSQL fetches those 100 heap tuples and applies WHERE category = 'database'.
  3. Caveat: If only 3 of the global Top-100 documents have category = 'database', the query returns only 3 rows even if hundreds of other 'database' documents exist with slightly lower BM25 scores!

Pre-Filtering (enable_prefilter = true)

When pre-filtering is enabled, vchord_bm25 checks heap visibility and evaluates filter acceptability before pushing candidate documents into the Block-WeakAnd Top-K heap:

  1. Non-matching documents never enter the Top-K heap and never raise the Block-WeakAnd pruning threshold θ\theta.
  2. The index scan continues until it finds KK documents that both match the WHERE filter and have the highest BM25 scores among all matching rows.

Enabling Pre-Filtering

In v0.2.2 – v0.3.0:

-- Enabled by default in v0.3.0; can be toggled per session:
SET bm25_catalog.enable_prefilter = true;

In main branch:

-- Enable per session:
SET bm25.prefilter = true;

-- Or enable by default on the index:
ALTER INDEX documents_bm25_idx SET (prefilter = true);

2. Partial BM25 Indexes for High-Selectivity Partition Keys

If you frequently filter by a small set of known partition keys (for example, status = 'published' or specific tenant_id values), you can create partial bm25 indexes:

CREATE INDEX documents_published_bm25
ON documents
USING bm25 (embedding bm25_ops)
WHERE status = 'published';

When querying a partial index, reference the partial index’s name in to_bm25query and include the matching WHERE condition:

SELECT id, passage
FROM documents
WHERE status = 'published'
ORDER BY embedding <&> to_bm25query('documents_published_bm25', tokenize('PostgreSQL BM25', 'my_tokenizer'))
LIMIT 10;

Choosing the Right Filtering Strategy

Filter Selectivity Recommended Strategy Why
High selectivity (50%–100% rows match) Post-filtering (enable_prefilter = false) with slightly higher bm25_limit Avoids heap fetches during the inner WAND loop; global Top-100 easily contains 10+ matching rows.
Medium selectivity (5%–50% rows match) Pre-filtering (enable_prefilter = true / bm25.prefilter = true) Guarantees exact Top-K recall over filtered rows without scanning irrelevant postings.
Fixed tenant / status subset Partial bm25 index (CREATE INDEX ... WHERE ...) or table partitioning Smaller index size, dedicated corpus IDF statistics, and zero wasted checks on other partitions.

2.4 - Hybrid Search (BM25 + VectorChord)

Combine sparse lexical BM25 search (vchord_bm25) and dense vector search (vchord) with Reciprocal Rank Fusion (RRF) and ColBERT reranking.

Modern retrieval pipelines combine dense vector search (vchord, which captures semantic similarity) with sparse lexical BM25 search (vchord_bm25, which captures exact keyword, identifier, and rare-term matches). Because both extensions run natively inside PostgreSQL, you can execute end-to-end hybrid search in a single SQL query with zero external orchestration.

CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;

SET search_path TO "$user", public, bm25_catalog, tokenizer_catalog;

-- 1. Create a BM25 tokenizer
SELECT create_tokenizer('bert', $$
model = "bert_base_uncased"
$$);

-- 2. Create a table storing both dense vectors and sparse bm25vectors
CREATE TABLE chunks (
    id SERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    dense_emb vector(768),
    bm25_emb bm25vector
);

-- 3. Create VectorChord dense index (vchordrq) and BM25 lexical index (bm25)
CREATE INDEX chunks_dense_idx ON chunks USING vchordrq (dense_emb vector_l2_ops);
CREATE INDEX chunks_bm25_idx  ON chunks USING bm25 (bm25_emb bm25_ops);

Method 1: Reciprocal Rank Fusion (RRF) in Pure SQL

Reciprocal Rank Fusion (RRF) combines rankings from multiple retrieval systems without requiring score calibration between L2/cosine distance and BM25 scores:

RRF_Score(d)=wdensek+rankdense(d)+wbm25k+rankbm25(d)\text{RRF\_Score}(d) = \frac{w_{\text{dense}}}{k + \text{rank}_{\text{dense}}(d)} + \frac{w_{\text{bm25}}}{k + \text{rank}_{\text{bm25}}(d)}

where kk is a smoothing constant (typically k=60k = 60).

WITH dense_results AS (
    SELECT
        id,
        RANK() OVER (ORDER BY dense_emb <-> '[0.012, -0.034, ...]'::vector) AS dense_rank
    FROM chunks
    ORDER BY dense_emb <-> '[0.012, -0.034, ...]'::vector
    LIMIT 100
),
bm25_results AS (
    SELECT
        id,
        RANK() OVER (
            ORDER BY bm25_emb <&> to_bm25query('chunks_bm25_idx', tokenize('PostgreSQL BM25 hybrid search', 'bert'))
        ) AS bm25_rank
    FROM chunks
    ORDER BY bm25_emb <&> to_bm25query('chunks_bm25_idx', tokenize('PostgreSQL BM25 hybrid search', 'bert'))
    LIMIT 100
),
fused AS (
    SELECT
        COALESCE(d.id, b.id) AS id,
        d.dense_rank,
        b.bm25_rank,
        COALESCE(1.0 / (60 + d.dense_rank), 0.0) +
        COALESCE(1.0 / (60 + b.bm25_rank), 0.0) AS rrf_score
    FROM dense_results d
    FULL OUTER JOIN bm25_results b ON d.id = b.id
    ORDER BY rrf_score DESC
    LIMIT 10
)
SELECT
    c.id,
    c.content,
    f.dense_rank,
    f.bm25_rank,
    round(f.rrf_score::numeric, 6) AS rrf_score
FROM fused f
JOIN chunks c ON c.id = f.id
ORDER BY f.rrf_score DESC;

Method 2: Hybrid Retrieval + ColBERT Late-Interaction Reranking

For even higher accuracy, you can retrieve candidate documents using vchordrq (dense) + vchord_bm25 (lexical) and rerank the union of candidates in PostgreSQL using VectorChord’s @# (max_sim) multivector ColBERT operator:

WITH dense_candidates AS (
    SELECT id FROM chunks
    ORDER BY dense_emb <-> $1::vector
    LIMIT 50
),
bm25_candidates AS (
    SELECT id FROM chunks
    ORDER BY bm25_emb <&> to_bm25query('chunks_bm25_idx', tokenize($2, 'bert'))
    LIMIT 50
),
candidate_ids AS (
    SELECT id FROM dense_candidates
    UNION
    SELECT id FROM bm25_candidates
)
SELECT
    c.id,
    c.content,
    -(c.colbert_emb @# $3::vector[]) AS colbert_maxsim_score
FROM candidate_ids u
JOIN chunks c ON c.id = u.id
ORDER BY c.colbert_emb @# $3::vector[] ASC
LIMIT 10;
Tip

See our engineering deep dives Supercharge Vector Search with ColBERT Rerank in PostgreSQL and Hybrid Search with Postgres Native BM25 and VectorChord for full BEIR benchmark comparisons of Dense + BM25 + ColBERT reranking inside PostgreSQL.

2.5 - BM25 & Block-WeakAnd Internals

Mathematical formulation and index architecture of Okapi BM25, 1-byte fieldnorm quantization, delta-bitpacking, and Block-WeakAnd in vchord_bm25.

This page details the exact scoring equations and data structures implemented in vchord_bm25 (crates/bm25/src/bm25.rs and src/algorithm/block_wand.rs).

1. Okapi BM25 Scoring Formula

Given a document DD and a query Q=q1,q2,dots,qmQ = \\{q_1, q_2, \\dots, q_m\\} with query term frequencies c(qi,Q)c(q_i, Q) and document term frequencies f(qi,D)f(q_i, D), vchord_bm25 computes:

textBM25(D,Q)=sumqiinQcapDc(qi,Q)cdottextIDF(qi)cdottextTF(qi,D)\\text{BM25}(D, Q) = \\sum_{q_i \\in Q \\cap D} c(q_i, Q) \\cdot \\text{IDF}(q_i) \\cdot \\text{TF}(q_i, D)

Inverse Document Frequency (IDF\text{IDF})

textIDF(qi)=lnleft(fracN+1.0n(qi)+0.5right)\\text{IDF}(q_i) = \\ln\\left(\\frac{N + 1.0}{n(q_i) + 0.5}\\right)

where:

  • NN is the total number of indexed documents in the table.
  • n(qi)n(q_i) is the number of documents containing token qiq_i (document frequency).

Saturated Term Frequency (TF\text{TF})

textTF(qi,D)=fracf(qi,D)cdot(k1+1)f(qi,D)+k1cdotleft(1−b+bcdotfrachat∣D∣textavgdlright)\\text{TF}(q_i, D) = \\frac{f(q_i, D) \\cdot (k_1 + 1)}{f(q_i, D) + k_1 \\cdot \\left(1 - b + b \\cdot \\frac{\\hat{|D|}}{\\text{avgdl}}\\right)}

where:

  • f(qi,D)f(q_i, D) is the term frequency of token qiq_i in document DD.
  • hat∣D∣\\hat{|D|} is the quantized document length (total token count in DD) decoded from its 1-byte fieldnorm ID.
  • textavgdl\\text{avgdl} is the average document length across all indexed documents.
  • k1in[1.2,2.0]k_1 \\in [1.2, 2.0] (default 1.2) controls term frequency saturation.
  • bin[0.0,1.0]b \\in [0.0, 1.0] (default 0.75) controls document length normalization.

2. 1-Byte Fieldnorm Quantization (FIELDNORM_TO_LENGTH)

Storing a 32-bit float or integer length for every document and computing floating-point division for every scored posting would consume excessive memory bandwidth and CPU cycles. Instead, vchord_bm25 quantizes document length ∣D∣|D| into a single byte (u8, 0..=255) using the 256-entry FIELDNORM_TO_LENGTH lookup table:

  • Exact for short documents (0 – 40 tokens): Values 0..=40 map 1:1 to exact token counts.
  • Logarithmically stepped for longer documents (41 – 255): Steps widen progressively (e.g., 42, 44, 46, ... up to 2,013,265,944 tokens).
  • 256-Entry Precomputed Score Cache: Because fieldnorm has only 256 possible values, vchord_bm25 precomputes the denominator term for all 256 possible document lengths once per query term:
s0(qi)=textIDF(qi)cdot(k1+1),qquads1[textfn]=k1cdotleft(1−b+bcdotfractextFIELDNORMTOLENGTH[textfn]textavgdlright)s_0(q_i) = \\text{IDF}(q_i) \\cdot (k_1 + 1), \\qquad s_1[\\text{fn}] = k_1 \\cdot \\left(1 - b + b \\cdot \\frac{\\text{FIELDNORM\\_TO\\_LENGTH}[\\text{fn}]}{\\text{avgdl}}\\right)

Scoring a candidate posting during scan execution reduces to a single table lookup and one division:

textscore(qi,D)=fracf(qi,D)cdots0(qi)f(qi,D)+s1[textfieldnorm(D)]\\text{score}(q_i, D) = \\frac{f(q_i, D) \\cdot s_0(q_i)}{f(q_i, D) + s_1[\\text{fieldnorm}(D)]}

3. Posting List Compression & Block-WeakAnd (Block-Max WAND)

Fixed 128-Document Blocks with Delta-Bitpacking

For each term in a sealed segment, the inverted list of (doc_id, term_frequency) pairs is partitioned into fixed blocks of 128 documents and compressed with Delta-Bitpacking:

  1. Document IDs within a block are sorted in ascending order and stored as deltas (Δi=doci−doci−1\Delta_i = \text{doc}_i - \text{doc}_{i-1}), bitpacked using the minimum bit width required by the largest delta in the block.
  2. Each block header stores the last document ID in the block (for fast shallow seeking) and the Block-Max TF / Fieldnorm pair $(\text{tf}{\max}, \text{fn}{\min})$ that achieves the highest possible BM25 contribution inside that 128-document block.

Block-WeakAnd Top-K Pruning Loop

During an ORDER BY vec <&> to_bm25query(...) LIMIT K scan:

flowchart TD
    A["Sort query term cursors by current doc_id"] --> B["Accumulate upper-bound max_score across cursors\\nuntil sum > current Top-K threshold θ (Pivot Document)"]
    B --> C{"Sum of Block-Max scores\\nat Pivot > θ ?"}
    C -- "No (Prune Block!)" --> D["Skip entire 128-doc block\\nwithout decoding postings"]
    C -- "Yes" --> E{"All leading cursors\\naligned at Pivot doc_id?"}
    E -- "No" --> F["Advance leading cursor to Pivot doc_id"]
    E -- "Yes" --> G["Decode block & evaluate exact BM25 score\\nUpdate Top-K min-heap and raise θ"]
    D --> A
    F --> A
    G --> A
  1. Pivot Selection: Query term cursors are ordered by their current doc_id. Starting from the first cursor, vchord_bm25 sums each term’s global upper-bound max_score until the cumulative sum exceeds the current KK-th best score threshold θ\theta in the Top-K min-heap. The cursor where the sum crosses θ\theta defines the pivot document.
  2. Block-Max Refinement: Without decompressing any posting block, vchord_bm25 shallow-seeks each cursor up to the pivot’s 128-document block and sums the tighter per-block upper bounds (block_max_score).
  3. Fast Block Skipping: If the sum of block_max_score values is ≤θ\le \theta, no document in the current block intersection can possibly enter the Top-K results—the scanner immediately jumps past the block without decoding document IDs or frequencies.
  4. Exact Evaluation: Only when the block upper bound exceeds θ\theta and cursors align on pivot_doc does vchord_bm25 decode the block, verify visibility/deletion bitmap (and optional prefilter), compute the exact BM25 score, and update the Top-K heap.

3 - Tokenizers (pg_tokenizer)

Complete guide to pg_tokenizer: text analyzers, character/token filters, pretrained and custom models, and multilingual tokenization.

pg_tokenizer (maintained by Pigsty at pgsty/pg_tokenizer) is the companion PostgreSQL extension for vchord_bm25 (pgsty/vchord_bm25) that provides full-text analyzers and vocabulary models to convert raw text into integer token ID arrays (INT[]) and sparse bm25vector embeddings.

Configure character filters, pre-tokenizers (Unicode, Regex, Jieba), stemmers (30 languages), stopwords, synonyms, and n-grams.

Use built-in models (llmlingua2, bert_base_uncased, wiki_tocken, gemma2b), train custom corpus models, or import HuggingFace & Lindera models.

3.1 - Tokenizer Architecture & Usage

Understand the two-stage pipeline of pg_tokenizer: Text Analyzer and Tokenization Model.

Two-Stage Tokenization Pipeline

A tokenizer in pg_tokenizer consists of up to two stages that transform a raw TEXT string into an INT[] array of integer token IDs:

flowchart LR
    A["Raw Text\\n(TEXT)"] --> B["Stage 1: Text Analyzer\\n1. character_filters\\n2. pre_tokenizer\\n3. token_filters"]
    B --> C["Normalized Tokens\\n(TEXT[])"]
    C --> D["Stage 2: Model\\nVocabulary Mapping / Subword BPE"]
    D --> E["Token ID Array\\n(INT[] → bm25vector)"]
  1. Text Analyzer (text_analyzer): Splits raw text into a TEXT[] array of normalized string tokens by applying a sequential pipeline of:
    • Character Filters (character_filters): Transform the raw string before splitting (e.g., to_lowercase, unicode_normalization).
    • Pre-tokenizer (pre_tokenizer): Splits the string into initial substrings (unicode_segmentation, regex, or Chinese jieba).
    • Token Filters (token_filters): Filter or transform individual tokens (skip_non_alphanumeric, stemmer, stopwords, synonym, pg_dict, ngram).
  2. Model (model): Maps the string tokens (or raw text, for standalone subword models like llmlingua2, bert_base_uncased, gemma2b, HuggingFace, or lindera) into integer token IDs (INT[]).
Note
  • Pretrained subword models (llmlingua2, bert_base_uncased, gemma2b), HuggingFace models, and Lindera models include their own built-in tokenization rules and do not require a separate text_analyzer stage.
  • Dictionary/corpus models (wiki_tocken and custom models created via create_custom_model) require a text_analyzer stage to split and normalize text before looking up token IDs in the model’s vocabulary table.

Creating and Using a Tokenizer

All configuration in pg_tokenizer uses TOML format passed as a dollar-quoted string ($$ ... $$).

Option 1: Single-Stage Tokenizer (Built-in Model)

SELECT create_tokenizer('llm_tok', $$
model = "llmlingua2"
$$);

SELECT tokenize('PostgreSQL is an object-relational database system.', 'llm_tok');

Option 2: Inline Text Analyzer + Model

You can define the text_analyzer pipeline directly inside create_tokenizer:

SELECT create_tokenizer('wiki_eng', $$
model = "wiki_tocken"

[text_analyzer.character_filters.to_lowercase]
[text_analyzer.pre_tokenizer.unicode_segmentation]
[[text_analyzer.token_filters.stopwords]]
words = "nltk_english"
[[text_analyzer.token_filters.stemmer]]
kind = "english_porter2"
$$);

SELECT tokenize('PostgreSQL is an object-relational database system.', 'wiki_eng');

Option 3: Reusable Named Text Analyzer + Custom Model

You can create a standalone text_analyzer once with create_text_analyzer and reference it across multiple custom models and tokenizers:

-- 1. Create a reusable text analyzer
SELECT create_text_analyzer('english_analyzer', $$
[character_filters.to_lowercase]
[pre_tokenizer.unicode_segmentation]
[[token_filters.skip_non_alphanumeric]]
[[token_filters.stopwords]]
words = "nltk_english"
[[token_filters.stemmer]]
kind = "english_porter2"
$$);

-- Preview how the text analyzer splits text into TEXT[] tokens:
SELECT apply_text_analyzer('PostgreSQL is a powerful database system!', 'english_analyzer');
-- Output: {postgresql,power,databas,system}

Managing Tokenizers

-- List configured tokenizers
SELECT name FROM tokenizer_catalog.tokenizer;

-- Drop a tokenizer
SELECT drop_tokenizer('wiki_eng');

3.2 - Text Analyzers & Filters

Complete configuration reference for character filters, pre-tokenizers, and token filters in pg_tokenizer.

A Text Analyzer processes raw text through three sequential stages:

  1. Character Filters (character_filters) — Zero or more character-level transformations on the full text string.
  2. Pre-tokenizer (pre_tokenizer) — Exactly one splitter that breaks the string into initial tokens.
  3. Token Filters (token_filters) — Zero or more token-level transformations, expansions, or filters.
SELECT create_text_analyzer('analyzer1', $$
character_filters = [
    "to_lowercase",
    { unicode_normalization = "nfkd" },
]
pre_tokenizer = "unicode_segmentation"
token_filters = [
    "skip_non_alphanumeric",
    { stopwords = "nltk_english" },
    { stemmer = "english_porter2" },
]
$$);
Tip

You can test any text analyzer directly using SELECT apply_text_analyzer('Sample text here', 'analyzer1'); to inspect the resulting TEXT[] array before wiring it to a model.


1. Character Filters (character_filters)

to_lowercase

Converts all characters in the input text to lowercase.

character_filters = ["to_lowercase"]
# or table syntax:
[character_filters.to_lowercase]

unicode_normalization

Applies Unicode Normalization Forms (UAX #15) to standardize composed/decomposed characters, accents, and full-width/half-width variants.

Supported modes:

  • "nfc" — Normalization Form C (Canonical Decomposition followed by Canonical Composition)
  • "nfd" — Normalization Form D (Canonical Decomposition)
  • "nfkc" — Normalization Form KC (Compatibility Decomposition followed by Canonical Composition)
  • "nfkd" — Normalization Form KD (Compatibility Decomposition)
[[character_filters]]
unicode_normalization = "nfkd"

2. Pre-Tokenizers (pre_tokenizer)

A text analyzer may specify at most one pre_tokenizer.

unicode_segmentation

Splits text on Unicode word boundaries according to the Unicode Standard Annex #29 (UAX #29) specification (via the Rust unicode-segmentation crate). Ideal for space-delimited and Western languages.

pre_tokenizer = "unicode_segmentation"
# or:
[pre_tokenizer.unicode_segmentation]

regex

Extracts tokens matching a custom regular expression (powered by the Rust regex crate):

[pre_tokenizer]
regex = '''\\w+'''

jieba (Chinese Word Segmentation)

Segments Chinese text into words using the jieba-rs dictionary and Hidden Markov Model (HMM):

Parameter Type Default Description
mode string "search" "full" (standard jieba.cut), "precise" (jieba.cut_all scanning all dictionary matches), or "search" (jieba.cut_for_search finer-grained segmentation for search recall).
enable_hmm bool true Enables HMM recognition for out-of-vocabulary words ("full" and "search" modes only).
[pre_tokenizer.jieba]
mode = "search"
enable_hmm = true

3. Token Filters (token_filters)

Token filters run in the exact order listed in your TOML array.

skip_non_alphanumeric

Drops any token that contains no alphanumeric characters (such as standalone punctuation marks ,, ., ! left behind by word segmentation).

[[token_filters]]
skip_non_alphanumeric = {}
# or in array shorthand:
token_filters = ["skip_non_alphanumeric"]

stemmer (Snowball Stemming — 30 Languages)

Reduces inflected words to their root stem using the Snowball stemming algorithms.

[[token_filters]]
stemmer = "english_porter2"

Supported stemmer values:

Language stemmer Value Language stemmer Value
Arabic arabic Lithuanian lithuanian
Armenian armenian Nepali nepali
Basque basque Norwegian norwegian
Catalan catalan Portuguese portuguese
Danish danish Romanian romanian
Dutch dutch Russian russian
English (Porter) english_porter Serbian serbian
English (Porter2) english_porter2 Spanish spanish
Esperanto esperanto Swedish swedish
Estonian estonian Tamil tamil
Finnish finnish Turkish turkish
French french Yiddish yiddish
German german
Greek greek
Hindi hindi
Hungarian hungarian
Indonesian indonesian
Irish irish
Italian italian

stopwords

Removes common stopwords that carry little lexical signal.

  • Built-in dictionaries:
    • "lucene_english" — Apache Lucene standard English stopword list
    • "nltk_english" — NLTK standard English stopword list
    • "iso_english" — Stopwords ISO English stopword list
  • Custom stopword dictionary: Create your own with create_stopwords(name, text) (one word per line) and reference it by name.
-- Create a custom stopword dictionary (one word per line)
SELECT create_stopwords('my_stopwords', $$
it
is
a
an
the
$$);

SELECT create_text_analyzer('text_analyzer1', $$
pre_tokenizer = "unicode_segmentation"
[[token_filters]]
stopwords = "my_stopwords"
$$);

SELECT apply_text_analyzer('it is a cat', 'text_analyzer1');
-- Output: {cat}

synonym

Normalizes synonymous terms to a single canonical token. Create a synonym dictionary with create_synonym(name, text) where each line contains space-separated synonyms (all words on a line are replaced by the first word on that line):

SELECT create_synonym('my_synonym', $$
postgres pgsql postgresql
index indices
$$);

SELECT create_text_analyzer('text_analyzer2', $$
pre_tokenizer = "unicode_segmentation"
[[token_filters]]
synonym = "my_synonym"
$$);

SELECT apply_text_analyzer('pgsql and postgresql have many indices', 'text_analyzer2');
-- Output: {postgres,and,postgres,have,many,index}

pg_dict (PostgreSQL Full-Text Dictionaries)

Delegates token filtering to any existing PostgreSQL Full-Text Search Dictionary (pg_ts_dict, including Simple, Ispell, Snowball, and Synonym dictionaries; note that Thesaurus dictionaries are not supported because they operate on multi-word phrases rather than single tokens):

[[token_filters]]
pg_dict = "english_stem"

ngram

Splits each token into character n-grams (useful for substring, prefix, CJK without dictionaries, or code/identifier search):

Parameter Type Default Range Description
min_gram int 1 1..=255 (<= max_gram) Minimum character length of generated n-grams.
max_gram int 2 1..=255 (>= min_gram) Maximum character length of generated n-grams.
preserve_original bool false true / false Whether to also emit the original unsplit token if its length falls outside [min_gram, max_gram].
[[token_filters.ngram]]
min_gram = 2
max_gram = 3
preserve_original = true

3.3 - Tokenization Models

Pretrained models (llmlingua2, bert_base_uncased, wiki_tocken, gemma2b), custom corpus-trained models, HuggingFace tokenizers, and Lindera models.

In pg_tokenizer, a Model maps string tokens (or raw text) to integer token IDs (INT[]). You can choose from four built-in pretrained models, train a custom vocabulary model directly on your PostgreSQL table, import any HuggingFace tokenizer.json, or use a Lindera morphological model.

1. Built-in Pretrained Models

Model Name Source / Architecture Requires text_analyzer? Memory Footprint Best For
llmlingua2 microsoft/llmlingua-2-xlm-roberta-large-meetingbank (XLM-RoBERTa multilingual subword) No (forbidden) ~200 MB (default preloaded) Multilingual general text out of the box without training a custom vocabulary.
bert_base_uncased google-bert/bert-base-uncased (WordPiece, 30,522 vocab) No (forbidden) ~15 MB General English text; fast startup and compact vocabulary.
wiki_tocken Snowolf/wiki-tocken (Built-in English Wikipedia token dictionary) Yes (required) ~30 MB English full-text search combined with custom stemmers, stopwords, and synonyms.
gemma2b google/gemma-2b (SentencePiece BPE, 256k vocab) No (forbidden) ~100 MB Code, technical identifiers, and multilingual LLM workloads.

Example: Using bert_base_uncased or llmlingua2

SELECT create_tokenizer('bert_tok', $$
model = "bert_base_uncased"
$$);

SELECT tokenize('VectorChord-BM25 brings Block-WeakAnd to PostgreSQL.', 'bert_tok');

2. Custom Corpus Models (create_custom_model)

For maximum BM25 accuracy and minimal memory footprint, you can build a custom vocabulary model directly from your own table column. Unlike subword LLM tokenizers (which split rare domain words into multiple sub-tokens), a custom model assigns a dedicated 1:1 integer ID to every unique normalized token produced by your text_analyzer—and weighs only a few megabytes in memory.

Option A: One-Step Helper (create_custom_model_tokenizer_and_trigger)

This convenience function creates the custom model, creates the tokenizer, populates the target bm25vector column for all existing rows, and installs a BEFORE INSERT OR UPDATE trigger that automatically learns new vocabulary tokens and updates the bm25vector column when rows change:

-- 1. Define how text should be analyzed
SELECT create_text_analyzer('my_analyzer', $$
character_filters = ["to_lowercase"]
pre_tokenizer = "unicode_segmentation"
token_filters = [
    "skip_non_alphanumeric",
    { stopwords = "nltk_english" },
    { stemmer = "english_porter2" },
]
$$);

-- 2. Create model, tokenizer, backfill embeddings, and attach trigger
SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name     => 'my_tokenizer',
    model_name         => 'my_model',
    text_analyzer_name => 'my_analyzer',
    table_name         => 'documents',
    source_column      => 'passage',
    target_column      => 'embedding'
);

Option B: Step-by-Step (create_custom_model Without Trigger)

If you prefer not to attach an automatic trigger (or want to control when embedding is populated):

-- 1. Build the custom model from the existing table column
SELECT create_custom_model('my_model', $$
table = "documents"
column = "passage"
text_analyzer = "my_analyzer"
$$);

-- 2. Create the tokenizer referencing the analyzer and custom model
SELECT create_tokenizer('my_tokenizer', $$
text_analyzer = "my_analyzer"
model = "my_model"
$$);

-- 3. Populate or refresh embeddings manually
UPDATE documents SET embedding = tokenize(passage, 'my_tokenizer');

3. Importing HuggingFace Tokenizers (create_huggingface_model)

You can import any HuggingFace tokenizers JSON specification (tokenizer.json) into PostgreSQL using create_huggingface_model.

In psql, you can fetch tokenizer.json directly from HuggingFace Hub into a variable using \set and pass it to create_huggingface_model:

\set content `wget -q -O - https://huggingface.co/google-bert/bert-base-uncased/resolve/main/tokenizer.json`
SELECT create_huggingface_model('bert_import', :'content');

SELECT create_tokenizer('bert_import_tok', $$
model = "bert_import"
$$);

Or pass the contents of a tokenizer.json file directly as a dollar-quoted string:

SELECT create_huggingface_model('bge_m3_model', $$
{
  "version": "1.0",
  ...
}
$$);

SELECT create_tokenizer('bge_m3_tok', $$
model = "bge_m3_model"
$$);

4. Lindera Morphological Models (create_lindera_model)

For East Asian languages such as Japanese, Korean, and Chinese that benefit from morphological dictionary analysis, pg_tokenizer integrates lindera.

Note

If you build pg_tokenizer from source, enable the desired Lindera dictionary features at compile time:

cargo pgrx install --release --features "lindera-ipadic lindera-ipadic-neologd lindera-unidic lindera-ko-dic lindera-cc-cedict"

Example configuration using ipadic with iteration mark normalization, NFKC Unicode normalization, character mapping, compound word merging, number normalization, Japanese part-of-speech stop-tags, Katakana stemming, and diacritical mark removal:

SELECT create_lindera_model('japanese_ipadic', $$
[segmenter]
mode = "normal"
  [segmenter.dictionary]
  kind = "ipadic"

[[character_filters]]
kind = "japanese_iteration_mark"
  [character_filters.args]
  normalize_kanji = true
  normalize_kana = true

[[character_filters]]
kind = "unicode_normalize"
  [character_filters.args]
  kind = "nfkc"

[[character_filters]]
kind = "mapping"
  [character_filters.args.mapping]
  "リンデラ" = "Lindera"

[[token_filters]]
kind = "japanese_compound_word"
  [token_filters.args]
  kind = "ipadic"
  tags = [
    "名詞,数",
    "名詞,接尾,助数詞",
  ]
  new_tag = "名詞,数"

[[token_filters]]
kind = "japanese_number"
  [token_filters.args]
  tags = ["名詞,数"]

[[token_filters]]
kind = "japanese_stop_tags"
  [token_filters.args]
  tags = [
    "接続詞",
    "助詞",
    "助詞,格助詞",
    "助詞,格助詞,一般",
    "助詞,格助詞,引用",
    "助詞,格助詞,連語",
    "助詞,係助詞",
    "助詞,副助詞",
    "助詞,間投助詞",
    "助詞,並立助詞",
    "助詞,終助詞",
    "助詞,副助詞/並立助詞/終助詞",
    "助詞,連体化",
    "助詞,副詞化",
    "助詞,特殊",
    "助動詞",
    "記号",
    "記号,一般",
    "記号,読点",
    "記号,句点",
    "記号,空白",
    "記号,括弧閉",
    "その他,間投",
    "フィラー",
    "非言語音",
  ]

[[token_filters]]
kind = "japanese_katakana_stem"
  [token_filters.args]
  min = 3

[[token_filters]]
kind = "remove_diacritical_mark"
  [token_filters.args]
  japanese = false
$$);

SELECT create_tokenizer('jp_tok', $$
model = "japanese_ipadic"
$$);

5. Model Preloading (add_preload_model)

Large models (llmlingua2 at ~200 MB, gemma2b at ~100 MB, japanese_ipadic) take several seconds and significant RAM to load on first query in each new PostgreSQL backend process. By preloading models during shared_preload_libraries startup, PostgreSQL loads the model once in the postmaster process and shares it across all backend connections via copy-on-write memory (llmlingua2 is preloaded by default):

-- Add a model to the preload list (persisted in $PGDATA/pg_tokenizer/preload_models)
SELECT add_preload_model('my_model');

-- List currently configured preload models (default: {llmlingua2})
SELECT list_preload_models();

-- Remove a model from the preload list (e.g., to save ~200 MB if llmlingua2 is unused)
SELECT remove_preload_model('llmlingua2');

Restart PostgreSQL after calling add_preload_model(...) (with shared_preload_libraries = 'pg_tokenizer.so' set in postgresql.conf) for preloading to take effect across all backends.

3.4 - Multilingual Recipes & Limitations

Complete tokenization configurations for English, Chinese (Jieba), Japanese (Lindera), and known limitations.

This page provides production-ready tokenization recipes for English, Chinese, and Japanese full-text BM25 search, followed by important operational limitations to keep in mind.

Recipe 1: English with Stemming & Stopwords (wiki_tocken)

Combines Unicode word segmentation, NLTK stopwords removal, and Porter2 stemming with the built-in Wikipedia vocabulary (wiki_tocken):

SELECT create_tokenizer('eng_wiki', $$
model = "wiki_tocken"

[text_analyzer.character_filters.to_lowercase]
[text_analyzer.pre_tokenizer.unicode_segmentation]
[[text_analyzer.token_filters.skip_non_alphanumeric]]
[[text_analyzer.token_filters.stopwords]]
words = "nltk_english"
[[text_analyzer.token_filters.stemmer]]
kind = "english_porter2"
$$);

SELECT tokenize('PostgreSQL databases are running analytical and vector workloads.', 'eng_wiki');

Recipe 2: English Custom Corpus Model with Trigger

From the upstream pg_tokenizer examples, this workflow builds a domain-specific English vocabulary from your documents table and keeps embeddings updated automatically via trigger:

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    passage TEXT,
    embedding bm25vector
);

SELECT create_text_analyzer('text_analyzer1', $$
pre_tokenizer = "unicode_segmentation"
[[character_filters]]
to_lowercase = {}
[[character_filters]]
unicode_normalization = "nfkd"
[[token_filters]]
skip_non_alphanumeric = {}
[[token_filters]]
stopwords = "nltk_english"
[[token_filters]]
stemmer = "english_porter2"
$$);

SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name => 'tokenizer1',
    model_name => 'model1',
    text_analyzer_name => 'text_analyzer1',
    table_name => 'documents',
    source_column => 'passage',
    target_column => 'embedding'
);

INSERT INTO documents (passage) VALUES
('PostgreSQL is a powerful, open-source object-relational database system. It has over 15 years of active development and a proven architecture that has earned it a strong reputation for reliability, feature robustness, and performance.'),
('Full-text search is a technique for searching a computer-stored document or a collection in a full-text database. Full-text search is distinguished from searches based on metadata or on parts of the original texts represented in databases (such as titles, abstracts, selected sections, or bibliographical references).'),
('BM25 is a ranking function used by search engines to estimate the relevance of documents to a given search query. It is based on the probabilistic retrieval framework developed in the 1970s and 1980s by Stephen E. Robertson, Karen Spärck Jones, and others.'),
('PostgreSQL supports advanced data types and performance optimization features, only available in the expensive commercial databases, such as Oracle and SQL Server. It is known for its resilience, integrity, and correctness.'),
('The BM25 algorithm computes a score for each document in a collection based on the query terms appearing in the document, regardless of their proximity within the document. It is a bag-of-words retrieval function that ranks a set of documents based on the query terms appearing in each document.'),
('PostgreSQL has a wide range of extensions and tools that make it suitable for various use cases, from small single-machine applications to large internet-facing applications with many concurrent users.'),
('Full-text search in PostgreSQL allows users to perform complex queries on text data, including phrase searching, ranking of results, and support for multiple languages through configurations and dictionaries.'),
('VectorChord-BM25 is a postgresql extension designed for bm25 ranking algorithm. Block-WeakAnd algorithm is implemented in it to accelerate the ranking process.'),
('pg_tokenizer is a postgresql extension designed for full-text search. It provides a set of functions to preprocess text data and generate tokens for indexing and searching.'),
('Look for organizations which use PostgreSQL for mission-critical or scaled applications. Look for organizations that have used PostgreSQL for a significant length of time.');

CREATE INDEX documents_passage_bm25 ON documents USING bm25 (embedding bm25_ops);

SELECT id, passage, embedding <&> to_bm25query('documents_passage_bm25', tokenize('PostgreSQL BM25', 'tokenizer1')) AS rank
FROM documents
ORDER BY rank
LIMIT 10;

Recipe 3: Chinese Full-Text Search (jieba + Custom Model)

For Chinese text, combine the jieba pre-tokenizer (mode = "search", enable_hmm = true) with a custom corpus model trained on your table (demonstrated below using passages from Qian Zhongshu’s Fortress Besieged / 《围城》):

CREATE TABLE zh_docs (
    id SERIAL PRIMARY KEY,
    passage TEXT,
    embedding bm25vector
);

-- 1. Create Chinese text analyzer with NFKD normalization and Jieba search mode
SELECT create_text_analyzer('text_analyzer1', $$
[character_filters.to_lowercase]
[character_filters.unicode_normalization]
kind = "nfkd"
[pre_tokenizer.jieba]
mode = "search"
enable_hmm = true
[[token_filters.skip_non_alphanumeric]]
$$);

-- 2. Create custom vocabulary model, tokenizer, and auto-update trigger
SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name     => 'tokenizer1',
    model_name         => 'model1',
    text_analyzer_name => 'text_analyzer1',
    table_name         => 'zh_docs',
    source_column      => 'passage',
    target_column      => 'embedding'
);

-- 3. Insert Chinese corpus (trigger automatically populates embedding)
INSERT INTO zh_docs (passage) VALUES
('红海早过了,船在印度洋面上开驶着,但是太阳依然不饶人地迟落早起,侵占去大部分的夜。'),
('夜仿佛纸浸了油,变成半透明体;它给太阳拥抱住了,分不出身来,也许是给太阳陶醉了,所以夕照霞隐褪后的夜色也带着酡红。'),
('到红消醉醒,船舱里的睡人也一身腻汗地醒来,洗了澡赶到甲板上吹海风,又是一天开始。'),
('这是七月下旬,合中国旧历的三伏,一年最热的时候。在中国热得更比常年利害,事后大家都说是兵戈之象,因为这就是民国二十六年【一九三七年】。'),
('这条法国邮船白康蒂号(VicomtedeBragelonne)正开向中国来。早晨八点多钟,冲洗过的三等舱甲板湿意未干,但已坐立满了人,法国人、德国流亡出来的犹太人、印度人、安南人,不用说还有中国人。'),
('海风里早含着燥热,胖人身体给风吹干了,蒙上一层汗结的盐霜,仿佛刚在巴勒斯坦的死海里洗过澡。'),
('毕竟是清晨,人的兴致还不没给太阳晒萎,烘懒,说话做事都很起劲。那几个新派到安南或中国租界当警察的法国人,正围了那年轻善撒娇的犹太女人在调情。'),
('俾斯麦曾说过,法国公使大使的特点,就是一句外国话不会讲;这几位警察并不懂德文,居然传情达意,引得犹太女人格格地笑,比他们的外交官强多了。'),
('这女人的漂亮丈夫,在旁顾而乐之,因为几天来,香烟、啤酒、柠檬水全靠这几个警察孝敬。船上买东西用法国钱,这犹太人只有一点儿沿着那位丹麦王子汉姆烈德(Hamlet)路线逃出来的德国马克。'),
('当时德国跟日本虽在谈恋爱,尚未结婚,到中国来的犹太人还是德国公民,有他们本国的保护。这船上的犹太人有过路到香港的,有到上海居留的,聚在一起在讨论生计,声音活像小洋磁盆里托的石子。'),
('几个年轻的安南人比手画脚,像猴子跳电线一样迅捷地交换着法语,华侨在旁羡慕地听着。这船上都是回家乡的中国人,像报纸上的标题,电影的字幕,标语,籍贯姓名年纪一张表上那几格,他们老早填满了,此番回来,不过把这些公式平滑无波地重演一次。'),
('只有鼻梁上架着一副黑眼镜的苏小姐,心算着她离国这几年,家里有什么变化,她父亲是否老了,她母亲新添了什么病痛,她弟弟结婚了没有。'),
('苏小姐在里昂研究法国文学,做了一篇《中国十八世纪的法国文学里的中国》的论文,刚得了博士。'),
('方鸿渐也到甲板上来了,在苏小姐身旁走过,向她脱帽招呼。苏小姐一壁应着,一壁想他真算厚脸皮,昨天向自己借了照相机,今天见了面,提都不提。');

-- 4. Create BM25 index and search
CREATE INDEX zh_docs_passage_bm25 ON zh_docs USING bm25 (embedding bm25_ops);

SELECT id, passage,
       embedding <&> to_bm25query('zh_docs_passage_bm25', tokenize('苏小姐 法国', 'tokenizer1')) AS rank
FROM zh_docs
ORDER BY rank
LIMIT 10;

Recipe 4: Japanese Full-Text Search (lindera IPADIC)

For Japanese text, create a Lindera IPADIC morphological model with iteration mark normalization, NFKC normalization, Japanese stop-tag filtering, and Katakana stemming (demonstrated below using passages from Natsume Sōseki’s I Am a Cat / 《吾輩は猫である》):

CREATE TABLE jp_docs (
    id SERIAL PRIMARY KEY,
    passage TEXT,
    embedding bm25vector
);

INSERT INTO jp_docs (passage) VALUES
('吾輩は猫である。名前はまだ無い。'),
('どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。'),
('吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。'),
('この書生というのは時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。'),
('ただ彼の掌に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。'),
('掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始であろう。この時妙なものだと思った感じが今でも残っている。'),
('第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶だ。その後猫にもだいぶ逢ったがこんな片輪には一度も出会わした事がない。'),
('のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと煙を吹く。どうも咽せぽくて実に弱った。'),
('これが人間の飲む煙草というものである事はようやくこの頃知った。'),
('この書生の掌の裏でしばらくはよい心持に坐っておったが、しばらくすると非常な速力で運転し始めた。'),
('書生が動くのか自分だけが動くのか分らないが無暗に眼が廻る。胸が悪くなる。到底助からないと思っていると、どさりと音がして眼から火が出た。'),
('それまでは記憶しているがあとは何の事やらいくら考え出そうとしても分らない。'),
('ふと気が付いて見ると書生はいない。たくさんおった兄弟が一疋も見えぬ。肝心の母親さえ姿を隠してしまった。'),
('その上今までの所とは違って無暗に明るい。眼を明いていられぬくらいだ。果てな何でも容子がおかしいと、のそのそ這い出して見ると非常に痛い。'),
('吾輩は藁の上から急に笹原の中へ棄てられたのである。');

SELECT create_lindera_model('lindera_ipadic', $$
[segmenter]
mode = "normal"
  [segmenter.dictionary]
  kind = "ipadic"

[[character_filters]]
kind = "japanese_iteration_mark"
  [character_filters.args]
  normalize_kanji = true
  normalize_kana = true

[[character_filters]]
kind = "unicode_normalize"
  [character_filters.args]
  kind = "nfkc"

[[character_filters]]
kind = "mapping"
  [character_filters.args.mapping]
  "リンデラ" = "Lindera"

[[token_filters]]
kind = "japanese_compound_word"
  [token_filters.args]
  kind = "ipadic"
  tags = [
    "名詞,数",
    "名詞,接尾,助数詞",
  ]
  new_tag = "名詞,数"

[[token_filters]]
kind = "japanese_number"
  [token_filters.args]
  tags = ["名詞,数"]

[[token_filters]]
kind = "japanese_stop_tags"
  [token_filters.args]
  tags = [
    "接続詞", "助詞", "助詞,格助詞", "助詞,格助詞,一般", "助詞,格助詞,引用",
    "助詞,格助詞,連語", "助詞,係助詞", "助詞,副助詞", "助詞,間投助詞",
    "助詞,並立助詞", "助詞,終助詞", "助詞,副助詞/並立助詞/終助詞",
    "助詞,連体化", "助詞,副詞化", "助詞,特殊", "助動詞", "記号",
    "記号,一般", "記号,読点", "記号,句点", "記号,空白", "記号,括弧閉",
    "その他,間投", "フィラー", "非言語音"
  ]

[[token_filters]]
kind = "japanese_katakana_stem"
  [token_filters.args]
  min = 3

[[token_filters]]
kind = "remove_diacritical_mark"
  [token_filters.args]
  japanese = false
$$);

SELECT create_tokenizer('tokenizer1', $$
model = "lindera_ipadic"
$$);

UPDATE jp_docs SET embedding = tokenize(passage, 'tokenizer1');

CREATE INDEX jp_docs_passage_bm25 ON jp_docs USING bm25 (embedding bm25_ops);

SELECT id, passage,
       embedding <&> to_bm25query('jp_docs_passage_bm25', tokenize('人間 吾輩', 'tokenizer1')) AS rank
FROM jp_docs
ORDER BY rank
LIMIT 10;

Operational Notes & Known Limitations

  1. Object Name Validation: All names for tokenizers, text analyzers, models, stopword dictionaries, and synonym dictionaries must contain only alphanumeric characters and underscores ([A-Za-z0-9_]), and must not start with a digit.
  2. Transaction Rollback & Per-Connection Object Cache: Each PostgreSQL connection caches tokenizer, text_analyzer, model, stopwords, and synonym objects in backend memory (DashMap). Because this in-memory object pool is not tied to PostgreSQL’s transaction rollback callbacks, rolling back a transaction (ROLLBACK) reverts the tokenizer_catalog tables on disk, but the current connection’s in-memory cache still holds the created object. After a ROLLBACK, explicitly call the corresponding drop_* function (or reconnect) to clear the in-memory cache:
    BEGIN;
    SELECT create_text_analyzer('text_analyzer1', $$
    pre_tokenizer = "unicode_segmentation"
    $$);
    ROLLBACK;
    
    -- Clear the stale in-memory cache entry in the current session (or reconnect)
    SELECT drop_text_analyzer('text_analyzer1');
    
    -- Now you can recreate it cleanly
    SELECT create_text_analyzer('text_analyzer1', $$
    pre_tokenizer = "unicode_segmentation"
    $$);
  3. Preload Models Across Replicas: add_preload_model writes the preload model list to the local $PGDATA/pg_tokenizer directory on the primary node. Because filesystem files outside PostgreSQL relations are not replicated via WAL, you should run add_preload_model on read replicas independently if you preload models there.
  4. Memory Consumption of Built-in LLM Models: Models like llmlingua2 (~200 MB), gemma2b (~100 MB), and lindera consume significant memory. Always use add_preload_model in production so the model is loaded once in the postmaster process rather than duplicated per connection, or prefer a custom corpus model (create_custom_model) which uses only a few megabytes.

4 - Reference & Benchmarks

Complete SQL reference, GUC parameters, benchmarks, and version history for vchord_bm25 and pg_tokenizer.

Complete technical reference for vchord_bm25 and pg_tokenizer:

Session GUCs and index storage parameters across v0.1–v0.3 (bm25_catalog.) and main (bm25.).

4.1 - SQL Reference

Complete SQL data types, operators, casts, index access methods, and functions for vchord_bm25 and pg_tokenizer.

vchord_bm25 Data Types

Data Type Schema Versions Text Representation / Structure Description
bm25vector bm25_catalog v0.1.0 – v0.3.0 {token_id:freq, ...} (e.g., {1:1, 2:2, 15:1}) Sparse vector mapping 32-bit unsigned token IDs to 32-bit term frequencies. Stored with STORAGE = EXTERNAL.
bm25query bm25_catalog v0.1.0 – v0.3.0 (index_oid regclass, query_vector bm25vector) Composite type pairing a target bm25 index OID with a query bm25vector.
bm25query public / ext schema main (vector tsvector, index regclass) Composite type pairing a query tsvector with a target bm25 index OID.

bm25vector Literal Syntax (v0.1 – v0.3)

-- Empty bm25vector
SELECT '{}'::bm25vector;

-- Explicit token_id:frequency pairs
SELECT '{1:1, 2:3, 10:2}'::bm25vector;

-- Implicit cast from int[] (automatically aggregates duplicate token IDs into counts)
SELECT ARRAY[1, 2, 2, 2, 10, 10]::bm25vector;
-- Output: {1:1, 2:3, 10:2}

vchord_bm25 Operators

Operator Left Arg Right Arg Return Type Versions Description
<&> bm25vector bm25query real (f32) v0.1.0 – v0.3.0 Computes the negative BM25 relevance score ($-\text{BM25}(D, Q)$). Supports index-accelerated ORDER BY ... ASC scans using bm25_ops.
<&> tsvector bm25query float8 (f64) main Computes the negative BM25 relevance score on native tsvector. Supports index-accelerated ORDER BY ... ASC scans using bm25_ops.
= bm25vector bm25vector bool v0.2.0 – v0.3.0 Exact equality comparison between two bm25vector values.
<> bm25vector bm25vector bool v0.2.0 – v0.3.0 Inequality comparison between two bm25vector values.

vchord_bm25 Functions

Function Signature Return Type Versions Description
to_bm25query(index_oid regclass, query_vector bm25vector) bm25query v0.1.1 – v0.3.0 Constructs a bm25query from a bm25 index name/OID and a query bm25vector (or INT[] via implicit cast).
to_bm25query(index_oid regclass, query_vector int[]) bm25query v0.3.0+ Explicit overload accepting an INT[] token ID array directly.
to_bm25query(vector tsvector, index regclass) bm25query main Constructs a bm25query from a native tsvector and target bm25 index name/OID.
search_bm25query(target_vector bm25vector, query bm25query) real v0.1.0 – v0.3.0 Underlying implementation function for bm25vector <&> bm25query.
_bm25_evaluate(left tsvector, right bm25query) float8 main Underlying implementation function for tsvector <&> bm25query.
bm25_page_inspect(index regclass, blkno int) text v0.3.0 Returns human-readable diagnostic metadata for block blkno of a bm25 index.

vchord_bm25 Access Method & Operator Class

Object Name Target Type Ordering Operator
Access Method bm25 Index Handler _bm25_amhandler
Operator Family bm25_ops USING bm25 OPERATOR 1 pg_catalog.<&> FOR ORDER BY float_ops
Operator Class bm25_ops bm25vector (v0.1–v0.3) / tsvector (main) Default operator class for USING bm25

pg_tokenizer Functions (tokenizer_catalog)

All pg_tokenizer functions reside in the tokenizer_catalog schema:

Tokenizer Management & Execution

Function Signature Return Type Description
create_tokenizer(name TEXT, config TEXT) void Creates a named tokenizer from a TOML configuration string.
drop_tokenizer(name TEXT) void Drops an existing tokenizer.
tokenize(text TEXT, tokenizer_name TEXT) INT[] Tokenizes text using tokenizer_name and returns an array of integer token IDs (implicitly castable to bm25vector).

Text Analyzer Management

Function Signature Return Type Description
create_text_analyzer(name TEXT, config TEXT) void Creates a reusable named text analyzer pipeline (character_filters, pre_tokenizer, token_filters).
drop_text_analyzer(name TEXT) void Drops a named text analyzer.
apply_text_analyzer(text TEXT, text_analyzer_name TEXT) TEXT[] Runs a text analyzer on text and returns the array of normalized string tokens (useful for debugging).

Model Management

Function Signature Return Type Description
create_custom_model(name TEXT, config TEXT) void Trains a custom vocabulary model on an existing table column using a text analyzer.
drop_custom_model(name TEXT) void Drops a custom model and its backing vocabulary table.
create_custom_model_tokenizer_and_trigger(tokenizer_name TEXT, model_name TEXT, text_analyzer_name TEXT, table_name TEXT, source_column TEXT, target_column TEXT) void Creates a custom model, creates a tokenizer, populates target_column on table_name, and attaches an automatic BEFORE INSERT OR UPDATE trigger.
create_huggingface_model(name TEXT, config TEXT) void Imports a model from a HuggingFace tokenizer.json string.
drop_huggingface_model(name TEXT) void Drops an imported HuggingFace model.
create_lindera_model(name TEXT, config TEXT) void Creates a Lindera morphological analysis model (e.g., ipadic) from a TOML config.
drop_lindera_model(name TEXT) void Drops a Lindera model.

Dictionary & Preload Management

Function Signature Return Type Description
create_stopwords(name TEXT, config TEXT) void Creates a custom stopword dictionary (one word per line).
drop_stopwords(name TEXT) void Drops a custom stopword dictionary.
create_synonym(name TEXT, config TEXT) void Creates a custom synonym dictionary (space-separated synonyms per line, mapped to the first word).
drop_synonym(name TEXT) void Drops a custom synonym dictionary.
add_preload_model(name TEXT) void Registers a model to be preloaded at PostgreSQL server startup (shared_preload_libraries).
remove_preload_model(name TEXT) void Removes a model from the startup preload list.
list_preload_models() TEXT[] Returns the list of models currently registered for preloading.

Internal Trigger & Helper Functions (tokenizer_catalog)

Function Signature Return Type Description
apply_text_analyzer_for_custom_model(text TEXT, text_analyzer_name TEXT) TEXT[] Applies text_analyzer_name and filters out tokens exceeding 2600 bytes (PostgreSQL B-tree UNIQUE index maximum key size) before inserting into a custom model vocabulary table.
custom_model_insert_trigger() trigger AFTER INSERT OR UPDATE trigger function that extracts new tokens from the source text column and inserts unseen tokens into tokenizer_catalog."model_<name>".
custom_model_tokenizer_set_target_column_trigger() trigger BEFORE INSERT OR UPDATE trigger function installed by create_custom_model_tokenizer_and_trigger that automatically populates NEW.<target_column> = tokenize(NEW.<source_column>, tokenizer_name).

pg_tokenizer Catalog Tables (tokenizer_catalog)

Table Name Columns Description
tokenizer_catalog.tokenizer name TEXT NOT NULL UNIQUE, config TEXT NOT NULL Stores registered tokenizer names and their serialized JSON/TOML configuration.
tokenizer_catalog.text_analyzer name TEXT NOT NULL UNIQUE, config TEXT NOT NULL Stores standalone reusable text analyzer definitions.
tokenizer_catalog.model name TEXT NOT NULL UNIQUE, config TEXT NOT NULL Stores registered custom, HuggingFace, and Lindera model definitions.
tokenizer_catalog.stopwords name TEXT NOT NULL PRIMARY KEY, config TEXT NOT NULL Stores built-in (lucene_english, nltk_english, iso_english) and custom stopword dictionaries.
tokenizer_catalog.synonym name TEXT NOT NULL PRIMARY KEY, config TEXT NOT NULL Stores custom synonym dictionaries.
tokenizer_catalog."model_<name>" id SERIAL PRIMARY KEY, token TEXT NOT NULL UNIQUE Backing vocabulary table created for each custom model <name>, mapping unique normalized tokens to 1-based integer IDs.

pg_tokenizer TOML Configuration Reference

Tokenizer Options (create_tokenizer)

Option Type Description
text_analyzer string (optional) Name of a pre-created text_analyzer. If omitted, inline character_filters, pre_tokenizer, and token_filters may be defined directly (when supported by model).
model string (required) Name of a built-in model (llmlingua2, bert_base_uncased, wiki_tocken, gemma2b) or a user-created model (custom, huggingface, or lindera).

Text Analyzer Options (create_text_analyzer)

Option Type Description
character_filters array / table List of character filters applied to the full string before splitting: to_lowercase, unicode_normalization (nfc, nfd, nfkc, nfkd).
pre_tokenizer string / table Single pre-tokenizer that splits the string into initial tokens: unicode_segmentation, regex (Rust regex string), or jieba (mode = "full" | "precise" | "search", enable_hmm = true | false).
token_filters array Ordered list of token filters: skip_non_alphanumeric, stemmer (30 Snowball stemmers), stopwords (lucene_english, nltk_english, iso_english, or custom), synonym, pg_dict (any pg_ts_dict except Thesaurus), ngram (min_gram, max_gram in 1..=255, preserve_original).

Custom Model Options (create_custom_model)

Option Type Description
table string (required) Target table name containing the source text corpus.
column string (required) Source TEXT column name on table used to build the vocabulary.
text_analyzer string (required) Name of the text_analyzer used to tokenize column into vocabulary terms.

4.2 - GUC & Index Parameters

Complete reference of session GUC parameters and index storage options for vchord_bm25.

Stable Releases (v0.1.0 – v0.3.0): bm25_catalog.* GUCs

In versions v0.1.0 through v0.3.0, all session GUC parameters use the bm25_catalog. prefix and can be changed per session or transaction by any user (GucContext::Userset):

Parameter Name Type Default Valid Range Description
bm25_catalog.bm25_limit integer 100 -1 .. 65535 Maximum number of top-scoring documents to return from a bm25 index scan. Set to -1 to disable Block-WeakAnd pruning and perform an exhaustive brute-force scan; set to 0 to return no rows. Must be ≥\ge your SQL LIMIT.
bm25_catalog.enable_index boolean true true / false Enables or disables the bm25 index scan in the PostgreSQL query planner. Set to false to force a sequential scan with exact search_bm25query evaluation.
bm25_catalog.enable_prefilter boolean true true / false Introduced in v0.2.2. When true, filters out documents that do not satisfy the query/visibility check before inserting them into the Block-WeakAnd Top-K heap.
bm25_catalog.segment_growing_max_page_size integer 4096 1 .. 1000000 Maximum size (in 8 KB pages, default 4096 = 32 MB) of the append-only growing segment before it is sealed into a compressed, delta-bitpacked read-only segment.

Examples (v0.1 – v0.3)

-- Increase Top-K window for deep pagination (e.g., LIMIT 500)
SET bm25_catalog.bm25_limit = 500;

-- Force brute-force scan over all index postings
SET bm25_catalog.bm25_limit = -1;

-- Enable prefiltering for queries with selective WHERE clauses
SET bm25_catalog.enable_prefilter = true;

-- Disable index scan to benchmark sequential scan baseline
SET bm25_catalog.enable_index = false;

Next-Gen main Branch: bm25.* GUCs & Index Reloptions

In the main branch rewrite, session GUCs use the bm25. prefix and work together with per-index storage parameters (WITH (...)):

Session GUC Parameters (main)

Parameter Name Type Default Valid Range Description
bm25.enable_scan boolean true true / false Enables or disables bm25 index scans in the planner.
bm25.limit integer 100 0 .. 65535 Maximum number of results returned by a bm25 index scan. If set to 0, falls back to the index’s limit storage parameter.
bm25.prefilter boolean false true / false Enables pre-filtering during bm25 index scans (overrides the index’s prefilter storage parameter when true).

Index Storage Parameters (WITH (...) in main)

Storage Parameter Type Default Valid Range Description
options string (TOML) k1 = 1.2, b = 0.75 k1: 1.2..2.0
b: 0.0..1.0
TOML string configuring the Okapi BM25 parameters k1k_1 and bb for the index.
limit integer 0 0 .. 65535 Per-index fallback search limit used when bm25.limit = 0.
prefilter boolean false true / false Per-index default prefilter setting.

4.3 - Benchmarks

QPS throughput and NDCG@10 retrieval accuracy benchmarks for VectorChord-BM25 against Elasticsearch, Lucene, ParadeDB, and PostgreSQL GIN.

Throughput Benchmark (QPS vs. Elasticsearch, Lucene, ParadeDB, PG GIN)

VectorChord-BM25’s Block-WeakAnd implementation with 128-document delta-bitpacked blocks and 1-byte fieldnorm quantization delivers up to 3x higher QPS than Tantivy-based PostgreSQL extensions (ParadeDB / pg_search) and an order of magnitude higher QPS than native PostgreSQL GIN indexes on Top-10 ranked queries:

VectorChord-BM25 RPS BenchmarkVectorChord-BM25 NDCG@10 Benchmark

Why vchord_bm25 Achieves High QPS

  1. Zero Cross-Engine Serialization: Unlike foreign-data-wrapper or external Tantivy/Lucene bridges, vchord_bm25 stores its segments directly inside PostgreSQL buffer pages (PostgresRelation), avoiding tuple marshaling overhead.
  2. Aggressive Block-Max Pruning: On multi-term queries, Block-WeakAnd skips over 80%–95% of 128-document posting blocks without decompressing them once the Top-10 threshold stabilizes.
  3. 256-Entry Precomputed BM25 Lookup Table: Quantizing document length into a 1-byte fieldnorm replaces expensive per-document floating-point math with a single array lookup.

Retrieval Accuracy Benchmark (NDCG@10 on BEIR & LoTTE)

The table below (from our BEIR / LoTTE Hybrid Search & ColBERT Reranking evaluation) compares retrieval accuracy (NDCG@10) across:

  • PostgreSQL vchord Dense Vector Search (Vector)
  • PostgreSQL vchord_bm25 Lexical Search (BM25)
  • Hybrid Search (vchord + vchord_bm25 with ColBERT Rerank)
Dataset Domain Vector (vchord) BM25 (vchord_bm25) Hybrid + ColBERT Rerank
scifact Scientific Fact-Checking 0.676 0.665 0.751
nfcorpus Bio-Medical IR 0.340 0.325 0.370
fiqa Financial QA 0.405 0.236 0.415
trec-covid Biomedical COVID-19 0.728 0.656 0.838
LoTTE-lifestyle (search) Lifestyle QA 0.735 0.635 0.779
LoTTE-lifestyle (forum) Lifestyle Forum 0.662 0.506 0.701
LoTTE-recreation (search) Recreation QA 0.642 0.564 0.708
LoTTE-technology (search) Technology QA 0.562 0.483 0.647
LoTTE-writing (search) Writing QA 0.726 0.674 0.793
Tip

Combining vchord_bm25 with vchord consistently outperforms either dense vector search or lexical BM25 alone across every evaluated dataset. See Hybrid Search (BM25 + VectorChord) for SQL query templates.

4.4 - Release History & API Evolution

Version changelog and SQL schema evolution of VectorChord-BM25 (vchord_bm25) from v0.1.0 to v0.3.0 and main.

This page tracks the release history and SQL schema evolution of vchord_bm25 (pgsty/vchord_bm25 / supervc-stack/VectorChord-bm25).

Version Summary Matrix

Version Schema Indexed Type Tokenizer Key Additions
main (Next-Gen) Extension schema tsvector Native to_tsvector (or custom) Complete storage & index engine rewrite on native tsvector; external sort parallel ambuild; index reloptions (options, limit, prefilter); bm25.* GUCs; PostgreSQL 18 support.
v0.3.0 bm25_catalog bm25vector pg_tokenizer Added bm25_page_inspect(regclass, int), to_bm25query(regclass, int[]) overload, and PostgreSQL 18 compatibility.
v0.2.2 bm25_catalog bm25vector pg_tokenizer Added bm25_catalog.enable_prefilter GUC for accurate filtered Top-K BM25 search with WHERE clauses.
v0.2.1 bm25_catalog bm25vector pg_tokenizer Bug fixes for growing/sealed segment transitions and vacuum cleanup.
v0.2.0 bm25_catalog bm25vector pg_tokenizer Added = and <> equality operators on bm25vector and implicit cast CREATE CAST (int[] AS bm25vector) AS IMPLICIT.
v0.1.1 bm25_catalog bm25vector pg_tokenizer Decoupled tokenization into standalone pg_tokenizer extension; changed to_bm25query(regclass, bm25vector) signature.
v0.1.0 bm25_catalog bm25vector Built-in unicode Initial release with bm25vector, bm25query, USING bm25, and Block-WeakAnd Top-K scan.

Detailed Upgrade Changes

v0.3.0 (sql/vchord_bm25--0.2.2--0.3.0.sql)

  • Added page diagnostic function:
    CREATE FUNCTION bm25_page_inspect(index regclass, blkno INT) RETURNS TEXT;
  • Added to_bm25query(regclass, int[]) convenience overload in main/late 0.3.x.

v0.2.2 (sql/vchord_bm25--0.2.1--0.2.2.sql)

  • Added prefiltering support in Block-WeakAnd (bm25_catalog.enable_prefilter GUC, enabled by default in 0.3.0).
  • Improved MVCC visibility checking during index scans to prevent dead/filtered tuples from polluting the Top-K threshold.

v0.2.0 (sql/vchord_bm25--0.1.1--0.2.0.sql)

  • Added = and <> operators for bm25vector.
  • Added implicit cast from int[] to bm25vector (_vchord_bm25_cast_array_to_bm25vector), allowing tokenize(text, name) output (INT[]) to be assigned or passed directly wherever bm25vector is expected.

v0.1.1 (sql/vchord_bm25--0.1.0--0.1.1.sql)

  • Extracted built-in create_tokenizer / tokenize functions into the dedicated pg_tokenizer extension (tokenizer_catalog), enabling rich text analyzers, Snowball stemmers, stopwords, synonyms, HuggingFace models, jieba, and lindera.
  • Simplified to_bm25query(index_oid regclass, query_vector bm25vector) to accept a bm25vector directly.

Next-Generation Rewrite (main Branch)

  • Replaced custom bm25vector with PostgreSQL’s native tsvector (CREATE OPERATOR CLASS bm25_ops FOR TYPE tsvector USING bm25), allowing users to index to_tsvector(...) columns directly with USING bm25.
  • Added parallel index build (bm25_parallel_build_main) with external merge sort and constant maintenance memory consumption.
  • Added configurable BM25 hyperparameters (k1∈[1.2,2.0]k_1 \in [1.2, 2.0], b∈[0.0,1.0]b \in [0.0, 1.0]) via WITH (options = $$ k1 = 1.2, b = 0.75 $$).