#DevOps

PostgreSQL 全文检索实战

不想上 Elasticsearch?PostgreSQL 自己就能做全文检索,中小规模够用。本文讲清 PG 全文检索:tsvector/tsquery、分词与字典、GIN 索引加速、ts_rank 排序、中文分词(zhparser/pg_jieba),以及和向量检索组合做混合检索。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年6月2日·更新于 2026年7月23日

PostgreSQL 全文检索实战:不上 ES 也能把搜索做明白

很多团队一聊到"搜索",反射动作就是上 Elasticsearch。然后为了一个搜索框,多拉一套 JVM 集群、多维护一套数据同步管道(把 PG 的数据用 Debezium/Logstash 喂进 ES)、多操心一份副本数和磁盘。搜索质量没多好,运维成本翻了一倍。

但现实是,绝大多数业务系统的搜索规模,根本到不了非 ES 不可的地步。百万级文档、毫秒级响应、带点相关性排序——PostgreSQL 自己就能干。本文把 PG 全文检索的链路从分词、索引、排序到中文处理一次讲清,照着做就能上线。

一、什么时候用 PG 全文检索就够了

先把场景界定清楚,别选错工具。

适合 PG 的场景:

  • 数据本来就躺在 PG 里,不想再搭一套同步链路
  • 文档量在百万到千万级,查询 QPS 中等
  • 搜索是辅助功能,不是核心产品(比如后台筛选、文章站内搜)
  • 需要事务一致性(写进去立刻能搜到,不要"最终一致"那套)
  • 团队没精力再养一个 ES

该老实上 ES 的场景:

  • 文档量过亿、要分布式分片
  • 搜索是核心营收功能,需要复杂的打分、聚合、召回调优
  • 有专门的搜索团队,愿意为相关性指标投入

一句话:别用大炮打蚊子,也别用牙签撬保险柜。PG 全文检索是性价比极高的中间档,值得作为默认选项。

二、三个核心概念:tsvector、tsquery、@@

PG 全文检索的整个世界由三样东西组成,吃透这三样,剩下的都是配置。

  • tsvector ——文档分词后的结构化表示,一组去重、带位置的词位(lexeme)
  • tsquery ——查询表达式,支持 &(与)、|(或)、!(非)、<->(相邻)
  • @@ ——匹配操作符,左边 tsvector,右边 tsquery,返回 bool

看一个最小可运行的例子:

sql
-- 把一段文本转成 tsvector
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- -> 'brown':3 'dog':9 'fox':4 'jump':6 'lazi':8 'quick':2'

-- 把用户输入转成 tsquery
SELECT to_tsquery('english', 'fox & dog');
-- -> 'fox' & 'dog'

-- 匹配
SELECT to_tsvector('english', 'The quick brown fox') @@ to_tsquery('english', 'fox & cat');
-- -> false

注意几件事:the 这种停用词被丢了;jumps 被还原成词干 jump;lazy 变成了 lazi。这就是后面要讲的"分词配置"在起作用。全文检索匹配的是词位,不是原始字符串,这是它和 LIKE 的本质区别——前者理解语言,后者只认字符。

三、分词与配置:to_tsvector 背后的字典

to_tsvector 的第一个参数是"配置名"(regconfig),它决定了怎么分词、去哪些停用词、怎么提取词干。PG 内置了几十种语言配置,常用的:

  • simple ——只按空白切分,全小写,不做词干、不过滤停用词。适合 ID、标签这种"不需要懂语言"的场景
  • english ——英文:去停用词 + Porter 词干提取
  • pg_catalog.french / german / ... ——其他语言对应配置
sql
-- 同一段话用不同配置,结果天差地别
SELECT to_tsvector('simple',  'Running runners ran quickly');  -- 不还原词干
SELECT to_tsvector('english', 'Running runners ran quickly');  -- 'quick':4 'ran':3 'run':1,2

坑点:很多人不知道 to_tsvector('body') 这种省略配置的写法,用的是会话参数 default_text_search_config。生产环境一定要显式指定配置,否则换台机器、改个参数,搜索结果就变了。

想加自定义停用词或词典?改 SHAREDIR/tsearch_data/ 下的 .stop 和 .syn 文件,再 ALTER TEXT SEARCH DICTIONARY 即可。例如把"公司"和"企业"当成同义词,搜一个能命中另一个:

sql
-- 同义词文件 my_syn.syn 内容:
# 每行一对同义词,空格分隔
公司 企业
CREATE TEXT SEARCH DICTIONARY my_syn (TEMPLATE = synonym, SYNONYMS = my_syn);
ALTER TEXT SEARCH CONFIGURATION english ALTER MAPPING FOR word WITH my_syn, english_stem;

词干提取带来的副作用要心里有数:它把 running/runs/ran 都收敛到 run,匹配召回率高,但也意味着你没法精确区分时态。做法律文本、合同条款这类需要逐字精确的场景,用 simple 配置反而更合适——别一种配置打天下。

四、GIN 索引:全文检索的命脉

没有索引的全文检索是灾难——它会逐行算 tsvector 再匹配,百万行就能把 CPU 顶满。GIN 索引是全文检索能上生产的必要条件。

最直接的写法:

sql
CREATE INDEX idx_articles_body_fts ON articles
  USING gin(to_tsvector('english', body));

注意:索引里的配置必须和查询里的配置完全一致(都是 'english'),否则优化器不会用这个索引。这也是为什么前面强调要固定配置。

更工程化的做法是用生成列,把 tsvector 物化出来,索引和查询都引用同一列,避免重复计算和配置漂移:

sql
ALTER TABLE articles
  ADD COLUMN body_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || body)) STORED;

CREATE INDEX idx_articles_body_tsv ON articles USING gin(body_tsv);

-- 查询时直接用这列,索引会被命中
SELECT id, title FROM articles WHERE body_tsv @@ plainto_tsquery('english', 'fox dog');

标题拼进 body,既能让标题命中权重更高(配合下面 ts_rank 的 setweight),也省了单独一个索引。plainto_tsquery 比手写 & 友好,它会把用户输入当成普通短语解析。

五、排序:ts_rank 与 ts_rank_cd

@@ 只告诉你匹不匹配,不告诉你谁更相关。给结果排序要用 ts_rank:

sql
SELECT id, title,
       ts_rank(body_tsv, plainto_tsquery('english', 'fox dog')) AS rank
FROM articles
WHERE body_tsv @@ plainto_tsquery('english', 'fox dog')
ORDER BY rank DESC
LIMIT 20;

ts_rank 基于命中词的频率和权重算分。ts_rank_cd 类似,但考虑覆盖密度(命中的词在文档里有多集中),通常对短文档更友好。

想给标题更高的权重,用 setweight 给 tsvector 的词位打 A/B/C/D 四级权重(A 最高):

sql
-- 生成列改成带权重的版本
SELECT setweight(to_tsvector('english', title), 'A')
     || setweight(to_tsvector('english', body),  'D');

实战里别迷信纯 ts_rank 排序——它不懂业务。常见做法是混合分数:ts_rank 当一项,再乘以时效衰减、点赞数、置顶标记,排出符合直觉的最终顺序:

sql
SELECT id, title,
       (ts_rank(body_tsv, q)
          * 0.4                              -- 关键词相关性
        + ln(pinned + likes + 1) * 0.3       -- 业务热度(对数压缩,避免大 V 一条屠榜)
        + exp(-age_days / 30.0) * 0.3)       -- 30 天半衰期
       AS final_score
FROM articles, plainto_tsquery('chinese', '并发') q
WHERE body_tsv @@ q
ORDER BY final_score DESC LIMIT 20;

三个系数(0.4/0.3/0.3)是调参点,没有标准答案,只能用真实点击日志反复回归。PG 全文检索给你原料,菜还得自己炒。

六、中文分词:zhparser 与 pg_jieba

这是 PG 全文检索最被诟病的一块:默认不支持中文分词。to_tsvector('english', '今天天气真好') 会把整句当一个词,基本废掉。

解决方法是装扩展,两个主流选择:

  • zhparser ——基于 SCWS,老牌、稳定、性能好
  • pg_jieba ——基于结巴分词,词典更现代、新词识别更好

以 zhparser 为例(RHEL/Debian 装完包后):

sql
CREATE EXTENSION zhparser;
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
  ADD MAPPING FOR n,v,a,i,e,l WITH simple;

-- 现在可以用 chinese 配置分词了
SELECT to_tsvector('chinese', '今天天气真好适合跑步');
-- -> '今天':1 '天气':2 '真好':3 '适合':4 '跑步':5

ADD MAPPING FOR n,v,a,... 决定哪些词性的词进 tsvector。把 u(助词)、r(代词)、c(连词)这些过滤掉,索引体积能小一半,搜索精度也更高——像"的、了、和、或者"这种词本来就不该参与匹配。

对比一下同样一句"我想查询订单的状态"在 simple 和 chinese 下的结果:

sql
SELECT to_tsvector('simple',  '我想查询订单的状态');   -- 整句一个词,没法搜
SELECT to_tsvector('chinese', '我想查询订单的状态');   -- '查询':3 '订单':4 '状态':6

后者才叫真正的"分词"。装好中文扩展是 PG 全文检索能不能在中文场景用的分水岭,这步不做,前面讲的都是空中楼阁。

坑点提醒:zhparser 的自定义词典放在 SCWS 的 etc 目录,改完要重启或 reload;pg_jieba 改 dict.txt 后需要重新加载扩展。生产环境别在容器里手动改词表,把词典做成配置文件挂载进去,纳入版本控制。

七、高亮命中:ts_headline

搜索结果得给用户看到为什么这条命中了。ts_headline 把命中词在原文里包一层标签,直接给前端用:

sql
SELECT id,
       ts_headline('chinese', body,
                   plainto_tsquery('chinese', '天气'),
                   'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=20')
FROM articles
WHERE body_tsv @@ plainto_tsquery('chinese', '天气');
-- -> 今天<mark>天气</mark>真好,适合出门跑步……

可选参数能控制截取长度、省略号、最大最小词数。注意 ts_headline 要回表读原文、再走一遍分词,别在超大结果集上无脑用,先 LIMIT 再 headline。

八、和 pgvector 组合做混合检索

到了 2026 年,光做关键词检索已经不够了——用户搜"怎么处理并发",你得能召回写着"锁机制""互斥"的文档,这是关键词匹配做不到的,得靠语义。

这时候 PG 的优势就出来了:同一张表里同时有关键词索引和向量索引,混合检索不需要跨系统 JOIN:

sql
-- 同一张表两套索引
CREATE INDEX idx_articles_body_tsv ON articles USING gin(body_tsv);
CREATE INDEX idx_articles_embedding ON articles USING hnsw (embedding vector_cosine_ops);

-- 混合检索:关键词过滤 + 语义召回,在数据库内融合
SELECT id, title,
       ts_rank(body_tsv, q)                       AS lex_rank,
       1 - (embedding <=> emb)                    AS sem_rank,
       -- 加权融合,BM25 思路 + 语义分
       ts_rank(body_tsv, q) * 0.6
         + (1 - (embedding <=> emb)) * 0.4        AS hybrid_rank
FROM articles,
     plainto_tsquery('chinese', '并发处理') q,
     (SELECT embedding AS emb FROM articles WHERE id = $1) e
WHERE body_tsv @@ q OR embedding <=> emb < 0.5
ORDER BY hybrid_rank DESC
LIMIT 20;

左边 ts_rank 走的是 BM25 的思路(词频+权重),右边 cosine 走的是语义相似度。两套分融合后排序,既保住关键词的精准命中,又拿到语义的泛化召回。权重系数(这里的 0.6/0.4)是调参点,得用真实查询日志反复调。

混合检索的水很深——召回融合有 RRF、线性加权、重排序等多种策略,向量索引选 HNSW 还是 IVFFlat 也各有取舍。这部分我们单独有篇文章细聊,这里只点一句:PG 是少数能把两套检索揉在一个 SQL 里的数据库,中小规模做混合检索,它几乎是最省心的选择。

小结

  • PG 全文检索 = tsvector(文档)+ tsquery(查询)+ @@(匹配),理解这三样,剩下的都是配置
  • GIN 索引是命脉,没有索引的全文检索就是 CPU 自焚;用生成列物化 tsvector,避免配置漂移
  • 排序用 ts_rank / ts_rank_cd,但别迷信纯相关性分,混入业务分才是工程做法
  • 中文必须装扩展:zhparser(SCWS)或 pg_jieba(结巴),词性映射能瘦一半索引
  • 和 pgvector 组合做混合检索,BM25 思路 + 语义召回在同一个 SQL 里融合,这是 PG 在 AI 时代独有的性价比

一句话收尾:搜索不是只有 Elasticsearch。能用 PostgreSQL 解决的搜索问题,就别再给自己多养一套集群——少一个组件,就少一个半夜被叫醒的理由。

Related

相关推荐