Postgres 插件提供 BM25 相关性排序全文搜索,对需要构建高效搜索功能的开发者有直接实用价值。
Postgres 现代化排序文本搜索。
简洁语法:ORDER BY content <@> 'search terms'
具有可配参数的 BM25 排序(k1、b)
适用于 Postgres 文本搜索配置(english、french、german 等)
表达式索引支持 JSONB 字段、多列搜索和文本转换
用于范围搜索和多语言表的部分索引
通过 Block-Max WAND 优化实现快速 top-k 查询
大表并行索引构建
支持分区表
一流的性能和可扩展性
🚀 状态:v1.4.0-dev - 生产就绪。
该项目的原始名称是 Tapir - Textual Analysis for Postgres Information Retrieval(Postgres 信息检索文本分析)。我们仍然使用 tapir 作为项目吉祥物,该名称在源代码的各处出现。
pg_textsearch 支持 PostgreSQL 17 和 18。
从 Releases 页面下载预构建二进制文件。可用于 Linux 和 macOS(amd64 和 arm64),PostgreSQL 17 和 18。
cd /tmp
git clone https://github.com/timescale/pg_textsearch
cd pg_textsearch
make
make install # may need sudo
pg_textsearch 必须通过 shared_preload_libraries 加载。在 postgresql.conf 中添加以下内容并重启服务器:
shared_preload_libraries = 'pg_textsearch' # add to existing list if needed
然后启用扩展(每个数据库一次):
CREATE EXTENSION pg_textsearch;
CREATE TABLE documents (id bigserial PRIMARY KEY, content text);
INSERT INTO documents (content) VALUES
('PostgreSQL is a powerful database system'),
('BM25 is an effective ranking function'),
('Full text search with custom scoring');
CREATE INDEX docs_idx ON documents USING bm25(content) WITH (text_config='english');
SELECT * FROM documents
ORDER BY content <@> 'database system'
LIMIT 5;
注意:<@> 返回负的 BM25 分数,因为 Postgres 仅支持 ASC 顺序的索引扫描。较低的分数表示更好的匹配。
索引会自动从列中检测。若要显式指定索引:
SELECT * FROM documents
ORDER BY content <@> to_bm25query('database system', 'docs_idx')
LIMIT 5;
text <@> 'query' - 对查询的文本评分(自动检测索引)
text <@> bm25query - 使用显式索引指定对文本评分
用 EXPLAIN 检查查询计划:
EXPLAIN SELECT * FROM documents
ORDER BY content <@> 'database system'
LIMIT 5;
对于小数据集,PostgreSQL 可能倾向于顺序扫描。强制索引使用:
SET enable_seqscan = off;
注意:即使 EXPLAIN 显示顺序扫描,<@> 和 to_bm25query 也总是使用索引来获取 BM25 评分所需的语料库统计信息(文档计数、平均长度)。
在 BM25 索引扫描中,过滤的交互有两种方式:
前置过滤 使用单独的索引(B-tree 等)在评分之前减少行数:
-- Create index on filter column
CREATE INDEX ON documents (category_id);
-- Query filters first, then scores matching rows
SELECT * FROM documents
WHERE category_id = 123
ORDER BY content <@> 'search terms'
LIMIT 10;
后置过滤 先应用 BM25 索引扫描,然后过滤结果。没有自己索引的列在 BM25 扫描后被过滤:
SELECT * FROM documents
WHERE length(content) > 100
ORDER BY content <@> 'search terms'
LIMIT 10;
前置过滤权衡:如果过滤匹配许多行(例如 100K+),对所有行进行评分成本高昂。当 BM25 索引能够使用 top-k 优化(ORDER BY + LIMIT)来避免对每个匹配文档进行评分时,效率最高。
前置过滤权衡:如果过滤匹配许多行(例如 100K+),对所有行进行评分成本高昂。当 BM25 索引能够使用 top-k 优化(ORDER BY + LIMIT)来避免对每个匹配文档进行评分时,效率最高。
后置过滤权衡:索引在过滤之前返回 top-k 结果。如果 WHERE 子句消除了大部分结果,可能获得比请求更少的行。增加 LIMIT 来补偿,然后在应用代码中重新限制。
后置过滤权衡:索引在过滤之前返回 top-k 结果。如果 WHERE 子句消除了大部分结果,可能获得比请求更少的行。增加 LIMIT 来补偿,然后在应用代码中重新限制。
最佳情况:用选择性条件进行前置过滤(匹配 <10% 的行),然后让 BM25 用 ORDER BY + LIMIT 对减少的集合进行评分。
最佳情况:用选择性条件进行前置过滤(匹配 <10% 的行),然后让 BM25 用 ORDER BY + LIMIT 对减少的集合进行评分。
这类似于 pgvector 中的过滤行为,其中近似索引也在索引扫描后应用过滤。
CREATE INDEX ON documents USING bm25(content) WITH (text_config='english');
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);
还支持不同的文本搜索配置:
-- English documents with stemming
CREATE INDEX docs_en_idx ON documents USING bm25(content) WITH (text_config='english');
-- Simple text processing without stemming
CREATE INDEX docs_simple_idx ON documents USING bm25(content) WITH (text_config='simple');
-- Language-specific configurations
CREATE INDEX docs_fr_idx ON french_docs USING bm25(content) WITH (text_config='french');
CREATE INDEX docs_de_idx ON german_docs USING bm25(content) WITH (text_config='german');
而不是普通列——对 JSONB 字段、多列拼接和文本转换很有用:
-- JSONB field extraction
CREATE INDEX ON events USING bm25 ((data->>'description'))
WITH (text_config='english');
SELECT * FROM events
ORDER BY (data->>'description') <@> to_bm25query('network error', 'events_expr_idx')
LIMIT 10;
-- Multi-column search
CREATE INDEX ON articles USING bm25 ((coalesce(title, '') || ' ' || coalesce(body, '')))
WITH (text_config='english');
-- Text transformation
CREATE INDEX ON docs USING bm25 ((lower(content)))
WITH (text_config='simple');
表达式必须计算为文本,且仅使用 IMMUTABLE 函数。查询必须在 ORDER BY 子句中重复相同的表达式。
通过添加 WHERE 子句为行的子集创建索引。当查询始终针对特定子集时,部分索引更小更快:
CREATE INDEX ON docs USING bm25 (content)
WITH (text_config='english')
WHERE status = 'published';
SELECT * FROM docs
WHERE status = 'published'
ORDER BY content <@> to_bm25query('search terms', 'docs_content_idx')
LIMIT 10;
部分索引需要通过 to_bm25query() 进行显式索引命名——隐式的 text <@> 'query' 语法会跳过它们。
表达式索引和部分索引可以结合使用:
CREATE INDEX ON events USING bm25 ((data->>'message'))
WITH (text_config='english')
WHERE (data->>'severity') = 'error';
对于包含多种语言文档的表,为每种语言创建一个部分索引,每个都使用相应的文本搜索配置:
ALTER TABLE docs ADD COLUMN lang CHAR(2) NOT NULL DEFAULT 'en';
CREATE INDEX docs_en_idx ON docs USING bm25 (content)
WITH (text_config='english') WHERE lang = 'en';
CREATE INDEX docs_de_idx ON docs USING bm25 (content)
WITH (text_config='german') WHERE lang = 'de';
CREATE INDEX docs_fr_idx ON docs USING bm25 (content)
WITH (text_config='french') WHERE lang = 'fr';
每个索引应用语言特定的词干提取和停用词。用匹配的谓词和索引名进行查询:
SELECT * FROM docs
WHERE lang = 'en'
ORDER BY content <@> to_bm25query('databases', 'docs_en_idx')
LIMIT 10;
bm25query 类型代表用于 BM25 评分的查询,可选索引上下文:
-- Create a bm25query with index name (required for WHERE clause and standalone scoring)
SELECT to_bm25query('search query text', 'docs_idx');
-- Returns: docs_idx:search query text
-- Embedded index name syntax (alternative form using cast)
SELECT 'docs_idx:search query text'::bm25query;
-- Returns: docs_idx:search query text
-- Create a bm25query without index name (only works in ORDER BY with index scan)
SELECT to_bm25query('search query text');
-- Returns: search query text
注意:在 PostgreSQL 18 中,使用单冒号(:)的嵌入式索引名语法允许查询规划器确定索引名,即使在早期评估 SELECT 子句表达式时也能保证兼容性。这确保了不同查询评估策略间的兼容性。
pg_textsearch 索引使用基于磁盘分页的 memtable(LSM 的 L0 层)来实现高效写入。memtable 在标准缓冲区锁的保护下进行修改,并通过 GenericXLog 记录 WAL。与其他索引类型一样,在加载数据后再创建索引速度更快。
-- Load data first
INSERT INTO documents (content) VALUES (...);
-- Then create index
CREATE INDEX docs_idx ON documents USING bm25(content) WITH (text_config='english');
pg_textsearch 支持并行构建索引,可以更快地为大型表创建索引。Postgres 会根据表的大小和配置自动使用并行工作进程。
-- Configure parallel workers (optional, uses server defaults otherwise)
SET max_parallel_maintenance_workers = 4;
SET maintenance_work_mem = '256MB'; -- At least 64MB required for parallel builds
-- Create index (parallel workers used automatically for large tables)
CREATE INDEX docs_idx ON documents USING bm25(content) WITH (text_config='english');
注意:规划器要求 maintenance_work_mem >= 64MB 才会启用并行索引构建。如果内存不足,构建过程会静默回退到串行模式。
使用并行构建时,你会看到以下通知:
NOTICE: parallel index build: launched 4 of 4 requested workers
对于分区表,每个分区会独立构建自己的索引;如果分区足够大,就会使用并行工作进程。这样可以高效地为超大型分区数据集创建索引。
索引将数据存储在跨多个层级的多个段中(类似于 LSM 树)。批量加载或持续增量插入之后,可能会积累多个段;将它们合并为一个段,可以减少查询时需要扫描的段数,从而提升查询速度:
SELECT bm25_force_merge('docs_idx');
这类似于 Lucene 的 forceMerge(1)。它会将所有段重写为单个段,并回收释放的页面。最适合在大批量插入之后使用,不应在持续写入流量期间使用。
Top-k 查询(ORDER BY ... LIMIT n)会启用 Block-Max WAND 优化,跳过那些不可能对排名靠前的结果产生贡献的倒排列表块。如果没有 LIMIT 子句,索引会回退到对最多 pg_textsearch.default_limit 个匹配文档进行评分。
-- Fast: BMW skips non-competitive blocks
SELECT * FROM documents ORDER BY content <@> 'search terms' LIMIT 10;
-- Slower: scores up to default_limit documents
SELECT * FROM documents ORDER BY content <@> 'search terms';
压缩默认开启,通常既能减小索引大小,也能提升查询性能(需要读取的页面更少)。只有当你观察到解压缩开销成为工作负载的瓶颈时,才应将其关闭:
SET pg_textsearch.compress_segments = off;
从 1.3.0 开始,L0 memtable 以文档记录页链的形式存储在索引关系本身之中,在标准缓冲区锁的保护下进行修改,并通过 GenericXLog 记录 WAL。它不使用共享内存 memtable,不使用自定义 WAL 资源管理器,也不需要 docid 页面恢复框架。PostgreSQL 原生的 WAL 重放机制(包括在线页面修复工具所使用的单页重建辅助功能)无需加载 pg_textsearch.so,即可重建每一个页面。规范请参阅 docs/memtable_v2.md。
自动溢写由两个相互补充的触发条件控制:
memtable_pages_threshold——每次插入后触发,条件是页面链已经增长并超过配置的页面数。默认值为 64 页(使用 8 KB 块时约为 512 KB),由于页面链始终保持较小,因此可以限制查询延迟。
bulk_load_threshold——在 COMMIT 时触发,条件是单个事务在 memtable 中积累了大量词项;适用于 COPY 或批量 INSERT,用来限制链式页面的增长。
-- Manual spill (forces the current chain to a new L0 segment)
SELECT bm25_spill_index('docs_idx');
VACUUM(包括 autovacuum 的插入阈值路径)运行时也会溢写 memtable,因此从执行 CREATE INDEX 到下一次服务器重启期间,尚未溢写的状态量会保持在有限范围内。
崩溃恢复:磁盘上的 memtable 链本身就是持久化记录。崩溃后,PostgreSQL 原生重放机制会恢复每一个页面;第一个后端进程打开索引时无需重建。
流复制:所有页面修改都通过标准 WAL 流进行复制。备用服务器可以原生重建每一个页面。
-- Check index usage
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelid::regclass::text ~ 'pg_textsearch';
CREATE TABLE articles (id serial PRIMARY KEY, title text, content text);
CREATE INDEX articles_idx ON articles USING bm25(content) WITH (text_config='english');
INSERT INTO articles (title, content) VALUES
('Database Systems', 'PostgreSQL is a powerful relational database system'),
('Search Technology', 'Full text search enables finding relevant documents quickly'),
('Information Retrieval', 'BM25 is a ranking function used in search engines');
-- Find relevant documents
SELECT title, content <@> 'database search' as score
FROM articles
ORDER BY score;
它还支持不同语言和自定义参数:
-- Different languages
CREATE INDEX fr_idx ON french_articles USING bm25(content) WITH (text_config='french');
CREATE INDEX de_idx ON german_articles USING bm25(content) WITH (text_config='german');
-- Custom parameters
CREATE INDEX custom_idx ON documents USING bm25(content)
WITH (text_config='english', k1=2.0, b=0.9);
BM25 索引会存储词频,但不会存储词项位置,因此无法原生计算 "database system" 这样的短语查询。你可以组合 BM25 排名与后置过滤器来模拟短语匹配:
-- BM25 ranks candidates; subquery over-fetches to account for
-- post-filter eliminating non-phrase matches
SELECT * FROM (
SELECT *, content <@> 'database system' AS score
FROM documents
ORDER BY score
LIMIT 100 -- over-fetch
) sub
WHERE content ILIKE '%database system%'
ORDER BY score
LIMIT 10;
由于后置过滤器会淘汰部分结果,因此内部的 LIMIT 应大于期望的结果数量。
pg_textsearch 不提供专用的分面操作符,但 Postgres 的标准查询机制可以处理常见的分面模式:
-- Filter by category (assumes a B-tree index on category)
SELECT * FROM documents
WHERE category = 'engineering'
ORDER BY content <@> 'search terms'
LIMIT 10;
-- Compute facet counts over top search results
SELECT category, count(*)
FROM (
SELECT category FROM documents
ORDER BY content <@> 'search terms'
LIMIT 100
) matches
GROUP BY category;
memtable 架构旨在支持高效写入,但持续的写入密集型工作负载目前尚未得到充分优化。对于初始数据加载,在数据加载完成后创建索引,要比增量插入更快。这是当前正在积极开发的领域。
目前,段压缩合并会在 memtable 溢写操作期间同步执行。写入密集型工作负载可能会在溢写期间遇到压缩合并延迟。未来版本计划支持后台压缩合并。
分区表上的 BM25 索引使用分区本地统计信息。每个分区分别维护自己的:
文档数量(total_docs)
平均文档长度(avg_doc_len)
用于计算 IDF 的各词项文档频率
针对单个分区的查询会使用该分区的统计信息计算准确的 BM25 分数。
跨多个分区的查询会分别计算各分区的分数,这些分数在不同分区之间可能无法直接比较。
示例:如果分区 A 有 1,000 个文档,而分区 B 有 10 个文档,那么词项 "database" 在各分区中的 IDF 值将会不同。来自两个分区的结果分数会处于不同的尺度上。
对于按时间分区的数据,如果分数的可比性很重要,请查询单独的分区。
采用查询会自然地以单个分区为目标的分区方案。
为搜索工作负载设计分区策略时,应考虑这种行为。
-- Query single partition (scores are accurate within partition)
SELECT * FROM docs
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
ORDER BY content <@> 'search terms'
LIMIT 10;
-- Cross-partition query (scores computed per-partition)
SELECT * FROM docs
ORDER BY content <@> 'search terms'
LIMIT 10;
pg_textsearch 继承了 PostgreSQL 对 tsvector 单词长度的限制,即 2,047 个字符。超过该限制的单词会在分词时被忽略(同时输出一条 INFO 消息)。此限制由 PostgreSQL 文本搜索实现中的 MAXSTRLEN 定义。
对于典型的自然语言文本,通常不会遇到这一限制。它可能会影响包含超长 token 的文档,例如 Base64 编码的数据、超长 URL 或拼接后的标识符。
这种行为与其他搜索引擎类似:
Elasticsearch:截断 token(可通过 truncate 过滤器配置,默认长度为 10 个字符)
Tantivy:默认截断至 255 字节
pg_textsearch 调用 Postgres 的 to_tsvector 来对文档文本进行令牌化。Postgres 将单个 tsvector 的词典上限设置为 1 MB(MAXSTRPOS)。如果文档的唯一令牌总量会超过该上限,则会在令牌化前将其拆分成块(目前为 256 KB),然后合并各块的词频。
块边界选择在每个窗口内最后一个 ASCII 空白处。这对于空白分隔脚本(拉丁文、西里尔文、希腊文、阿拉伯文等)是正确的。对于非空白分隔脚本(中日韩文、泰文、老挝文、高棉文),超大文档仍会被索引,但块边界可能落在语言感知令牌化器认为的词的中间。实际上这是可接受的,因为 Postgres 的默认文本搜索解析器对这些脚本也不会发出逐词令牌。如果使用自定义文本搜索配置,该配置的解析器为这些脚本之一生成词级令牌,则超大文档可能会产生与单次令牌化略有不同的词条数。
大型 CJK(或其他非空白分隔)文档的解决方案:在应用层将文档拆分成更小的块,索引 text[] 列而非 text。pg_textsearch 逐元素地索引数组,BM25 得分与连接这些元素成单个文本值后的结果相同,因此你保持排名质量的同时控制块边界的位置。将其与来自 zhparser(中文)等扩展的 CJK 感知文本搜索配置配对,使每个块都获得词级令牌化:
CREATE EXTENSION zhparser;
CREATE TEXT SEARCH CONFIGURATION public.chinese_zh (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION public.chinese_zh
ADD MAPPING FOR n,v,a,i,e,l WITH simple;
CREATE TABLE docs (id bigserial PRIMARY KEY, content text[]);
CREATE INDEX docs_bm25 ON docs USING bm25(content)
WITH (text_config='public.chinese_zh');
隐式的 text <@> 'query' 语法依赖规划器钩子来自动检测 BM25 索引。这些钩子不在 PL/pgSQL DO 块、函数或存储过程内运行。
在 PL/pgSQL 内,使用 to_bm25query() 配合显式索引名:
-- 这在 PL/pgSQL 中不工作:
-- SELECT * FROM docs ORDER BY content <@> 'search terms' LIMIT 10;
-- 改用显式索引名:
SELECT * FROM docs
ORDER BY content <@> to_bm25query('search terms', 'docs_idx')
LIMIT 10;
常规 SQL 查询(PL/pgSQL 之外)支持两种形式。
-- 列出可用的文本搜索配置
SELECT cfgname FROM pg_ts_config;
-- 列出 BM25 索引
SELECT indexname FROM pg_indexes WHERE indexdef LIKE '%USING bm25%';
如果你的机器有多个 Postgres 安装,指定 pg_config 的路径:
export PG_CONFIG=/Library/PostgreSQL/18/bin/pg_config # or 17
make clean && make && make install
如果出现编译错误,安装 Postgres 开发文件:
# Ubuntu/Debian
sudo apt install postgresql-server-dev-17 # for PostgreSQL 17
sudo apt install postgresql-server-dev-18 # for PostgreSQL 18
可用的配置取决于你的 Postgres 安装:
# SELECT cfgname FROM pg_ts_config;
cfgname
------------
simple
arabic
armenian
basque
catalan
danish
dutch
english
finnish
french
german
greek
hindi
hungarian
indonesian
irish
italian
lithuanian
nepali
norwegian
portuguese
romanian
russian
serbian
spanish
swedish
tamil
turkish
yiddish
(29 rows)
更多语言支持可通过扩展获得——见下面的中文全文搜索。
pg_textsearch 通过索引的 text_config(标准 PostgreSQL 文本搜索配置)对文档和查询进行令牌化,因此中文支持只是选择一个中文感知的配置;pg_textsearch 本身不需要特定语言的代码。PostgreSQL 的内置解析器不会将中文文本拆分成词,所以将 pg_textsearch 与中文分词器(如基于 SCWS 分割库的 zhparser)配对。在托管平台上,该扩展必须在允许列表中(例如,zhparser 不在 Azure Database for PostgreSQL 灵活服务器的允许列表中)。
zhparser 作为文本搜索解析器打包,所以基于它构建的配置可直接接入 bm25 索引。CREATE EXTENSION zhparser 也会注册该解析器;映射你要索引的令牌类型,并通过模式限定名称引用该配置。
CREATE EXTENSION zhparser;
-- 映射 zhparser 的内容令牌类型(名词、动词、形容词、习语、
-- 感叹词、短语)。对配置使用模式限定名称,以便
-- bm25 索引构建可以按名称解析它。
CREATE TEXT SEARCH CONFIGURATION public.chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION public.chinese
ADD MAPPING FOR n, v, a, i, e, l WITH simple;
CREATE TABLE docs (id bigserial PRIMARY KEY, content text);
CREATE INDEX docs_bm25 ON docs USING bm25 (content)
WITH (text_config='public.chinese');
-- 查询字符串用相同的配置进行令牌化:
SELECT id FROM docs
ORDER BY content <@> to_bm25query('机器学习', 'docs_bm25')
LIMIT 10;
此设置的端到端回归测试位于 test/sql/chinese.sql,通过 make test-chinese 运行(见中文 CI 工作流)。对于大型中文文档,上面的大文档解决方案将相同的 n,v,a,i,e,l zhparser 映射应用于分块的 text[] 列。
这些函数仅供调试和开发使用。其接口在未来版本中可能无通知地更改。标有 † 的函数需要超级用户权限。
额外的文件写入调试函数(bm25_dump_index(text, text) 和 bm25_debug_pageviz)仅在调试构建中可用(使用 -DDEBUG_DUMP_INDEX 编译)。
-- 合并所有段为一个(最好在大量加载后)
SELECT bm25_force_merge('docs_idx');
-- 强制溢出到磁盘(返回溢出的条目数)
SELECT bm25_spill_index('docs_idx');
-- 快速索引统计概览