外观
从 2 秒到 80 毫秒:PG 中文全文检索调优实录
问题背景:站内搜索为什么不建议只靠 LIKE
很多业务的第一版搜索都从 LIKE '%关键词%' 开始。数据量小的时候它足够简单,但随着文章、商品、工单标题增长,问题会迅速暴露:前后通配无法使用普通 B-Tree 索引,无法理解“数据库/PostgreSQL/PG”之间的词项关系,也不能根据标题、正文、标签做权重排序。
PostgreSQL 内置全文检索能力由 tsvector、tsquery、GIN 索引和排名函数组成。英文场景开箱即用,中文场景则需要解决分词问题。本文以一个站内文章搜索为背景,给出从插件安装、数据建模、索引设计到 tsquery 调优的完整落地方案。
方案对比:pg_jieba、zhparser 与简单字符切分
中文全文检索常见有三类方案:
- 简单字符切分:把中文拆成单字或 bigram,兼容性最好,但召回噪声大,索引体积也偏大。
- pg_jieba:基于结巴分词,接入成本低,适合多数常规业务。
- zhparser:基于 SCWS,PostgreSQL 生态使用广泛,支持自定义词典,适合需要长期维护词库的站内搜索。
下面以 zhparser 为例。安装扩展后需要创建中文文本搜索配置,并将其映射到 zhparser 解析器。
sql
-- 注意:扩展需要数据库服务器已安装 zhparser 软件包
CREATE EXTENSION IF NOT EXISTS zhparser;
-- 创建中文全文检索配置
CREATE TEXT SEARCH CONFIGURATION chinese_zh (PARSER = zhparser);
-- noun、verb、adj 等 token 类型参与索引;可按业务调整
ALTER TEXT SEARCH CONFIGURATION chinese_zh
ADD MAPPING FOR n,v,a,i,e,l,j
WITH simple;
-- 验证分词效果
SELECT token, lexemes
FROM ts_debug('chinese_zh', 'PostgreSQL 中文全文检索与 tsquery 调优');如果暂时无法安装系统扩展,可以先用 trigram 作为过渡方案:pg_trgm 适合模糊匹配和子串搜索,但它不是语义分词,适合作为兜底而不是主搜索方案。
检索架构:写入、索引与查询链路
核心思路是不要在查询时临时对全表执行 to_tsvector,而是在写入或生成列中固化 tsvector,再建立 GIN 索引。查询侧把用户输入转换为安全的 tsquery,最后用 @@ 做匹配、ts_rank 或业务分值排序。
实操步骤:文章表、生成列与 GIN 索引
先建立文章表,并增加一个带权重的全文检索生成列。标题通常比正文更重要,因此给标题设置 A 权重,正文设置 B 权重。
sql
DROP TABLE IF EXISTS articles;
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
status text NOT NULL DEFAULT 'published',
published_at timestamptz NOT NULL DEFAULT now(),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('chinese_zh', coalesce(title, '')), 'A') ||
setweight(to_tsvector('chinese_zh', coalesce(body, '')), 'B') ||
setweight(to_tsvector('simple', array_to_string(tags, ' ')), 'C')
) STORED
);
-- GIN 是全文检索最常用索引;写入频繁时可评估 gin_pending_list_limit
CREATE INDEX articles_search_vector_idx
ON articles
USING GIN (search_vector);
-- 搜索列表通常还要按状态过滤、按发布时间倒序
CREATE INDEX articles_published_time_idx
ON articles (published_at DESC)
WHERE status = 'published';插入一批测试数据:
sql
INSERT INTO articles (title, body, tags) VALUES
('PostgreSQL 全文检索实战', '介绍中文分词、tsquery、GIN 索引和执行计划优化。', ARRAY['postgresql','搜索']),
('MySQL 与 PG 怎么选', '从事务、索引、JSON、全文检索和运维成本对比数据库。', ARRAY['mysql','选型']),
('一次慢查询治理记录', '通过 EXPLAIN ANALYZE 发现 Bitmap Heap Scan 耗时过高。', ARRAY['性能','explain']),
('站内搜索召回排序指南', '讲解关键词高亮、权重、同义词词典和短语匹配。', ARRAY['搜索','排序']);tsquery 用法:从安全输入到高级布尔查询
普通搜索框不要直接拼接 to_tsquery,因为 to_tsquery 要求用户输入符合严格语法,单独输入 &、! 或未闭合括号都可能报错。更安全的方式是使用 plainto_tsquery 或 websearch_to_tsquery。
sql
-- 普通关键词:自动按 AND 处理分词结果
SELECT id, title
FROM articles
WHERE status = 'published'
AND search_vector @@ plainto_tsquery('chinese_zh', '中文 分词 索引')
ORDER BY ts_rank(search_vector, plainto_tsquery('chinese_zh', '中文 分词 索引')) DESC,
published_at DESC
LIMIT 20;
-- 推荐:支持站内搜索常见语法,且对异常输入更宽容
-- “PostgreSQL -MySQL” 表示要求 PG,排除 MySQL
SELECT id, title,
ts_rank(search_vector, websearch) AS rank_score
FROM articles,
LATERAL websearch_to_tsquery('chinese_zh', 'PostgreSQL -MySQL') AS websearch
WHERE status = 'published'
AND search_vector @@ websearch
ORDER BY rank_score DESC, published_at DESC
LIMIT 20;如果希望“全文检索 索引”作为连续短语出现,可以使用 phraseto_tsquery。它会生成带距离约束的 tsquery,比单纯要求两个词同时出现更精确。
sql
SELECT id, title
FROM articles
WHERE search_vector @@ phraseto_tsquery('chinese_zh', '全文 检索')
ORDER BY published_at DESC;需要同义词时,可以维护一个业务词典表,并在应用层把查询词扩展为 (PG OR PostgreSQL)。但不要无节制扩展同义词,否则 tsquery 变宽后会大幅增加候选集合。
性能调优:用 EXPLAIN 验证索引是否命中
判断 SQL 是否走 GIN 索引,不能只看“结果正确”,必须查看执行计划。典型高效计划是 Bitmap Index Scan 配合 Bitmap Heap Scan,当结果集很小时也可能出现普通索引扫描。
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title
FROM articles
WHERE status = 'published'
AND search_vector @@ websearch_to_tsquery('chinese_zh', '全文 检索')
ORDER BY published_at DESC
LIMIT 20;如果看到 Seq Scan,并且 Filter 中对 search_vector 或 to_tsvector 做计算,通常是以下原因:表太小、统计信息过旧、查询条件无法使用索引,或在索引列上包了函数。
更新统计信息并观察 GIN 索引内部状态:
sql
ANALYZE articles;
-- 查看索引膨胀与体积,需要 pgstattuple 扩展
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('articles_search_vector_idx');对于高频写入表,GIN 索引的 pending list 太大可能造成查询变慢。可以在会话或库级别调整:
sql
-- pending list 达到阈值后由 autovacuum 批量合并
ALTER TABLE articles SET (gin_pending_list_limit = 2048);
-- 大批量导入后手动清理分析
VACUUM (ANALYZE) articles;当过滤条件同时包含状态、时间和全文条件时,不要盲目追求一个复合索引解决所有问题。更实用的做法是先用 GIN 缩小候选集,再通过 status、published_at 做过滤和排序;若热点查询固定,再评估带条件的 GIN 索引。
sql
-- 只索引已发布内容,减小索引体积
CREATE INDEX articles_published_search_idx
ON articles USING GIN (search_vector)
WHERE status = 'published';避坑总结:中文全文检索的高频问题
1. 查询很快但总数很慢
常见现象是第一页搜索 80 毫秒,执行 COUNT(*) 却要 2 秒。原因是总数必须确认所有匹配行,而 LIMIT 只需要拿到前 20 条。修正方法是限制可翻页深度,用游标分页替代深分页;总数可以异步估算,或对超过阈值的结果显示“1000+”。
2. 中文词明明存在却搜不到
典型原因是索引和查询使用了不同的文本搜索配置,例如索引用 simple,查询用 chinese_zh,或者新增专有词后没有重建生成列。修正方法是统一配置名,并在更新词典后执行 VACUUM FULL 或重建生成列索引。
sql
REINDEX INDEX articles_search_vector_idx;
ANALYZE articles;3. 直接拼接用户输入导致 tsquery 报错
错误现象是输入括号、引号、&、! 后出现 tsquery 语法错误。修正方法是对外网搜索框统一使用 websearch_to_tsquery,只有后台高级检索才允许白名单方式拼接布尔操作符,并对原始输入做长度限制。
4. GIN 索引存在但执行 Seq Scan
先不要怀疑优化器。检查表数据量是否小到顺序扫描更便宜,search_vector 是否由函数临时生成,分词配置是否导致查询词无法命中,以及统计信息是否过旧。使用 EXPLAIN (ANALYZE, BUFFERS) 对比命中行数,执行 ANALYZE 后再判断。
5. 只按 ts_rank 排序不符合业务预期
ts_rank 只反映词频、密度和权重,不考虑发布时间、点击率、是否下架。站内搜索更推荐组合分值:标题命中给高权重,近期内容加权,低质或过期内容降权。不要把数据库全文检索当成完整搜索引擎,但在百万级数据、查询条件明确、运维资源有限的场景下,PostgreSQL + 中文分词 + GIN + tsquery 往往是性价比很高的落地方案。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。