1. 为什么我最终选择 PostgreSQL 做全文检索
很多人一听到“全文检索”四个字,第一反应就是上搜索引擎框架。确实,搜索引擎框架在分布式、高并发、海量数据场景下是标杆级方案,但那类技术栈的运维成本和学习曲线也实打实地摆在那里。对于绝大多数数据量在千万级以下、并发量可控、又要和现有业务数据库强耦合的中小型系统来说,为搜索单独引入一套重型基础设施,多少有点杀鸡用牛刀。
我自己在几个实际项目里反复对比过,发现 PostgreSQL 内置的全文检索能力被严重低估了。它不是那种“能用但很鸡肋”的附赠功能,而是经过完整设计、性能足够稳定、和 SQL 生态无缝整合的高级特性。只需要在合适的表上加一个 GIN 索引,写几条规范的查询语句,就能在业务代码里获得搜索引擎级别的检索体验。关键是你不需要维护额外的索引集群,不需要考虑两边数据同步的延迟和一致性,也不需要学习一套完全不同的 API 语法。
PostgreSQL 全文检索说到底解决的是“怎么在普通关系型数据库里做好关键字匹配”这件事。MySQL 的 LIKE '%关键词%' 在数据量上来之后性能会急剧恶化,因为无法走索引,只能全表扫描;PostgreSQL 原生的全文检索则通过 tsvector 与 GIN 索引的组合,把文本预处理、分词、排序整合成一条完整的管道。我们的业务数据在哪,搜索能力就长在哪,不需要搬运数据。
这篇文章我会从底层原理讲到实际落地的完整方案,包括中文分词的特殊处理、GIN 索引的调优细节、以及我在生产环境中踩过的那些坑。不管你是在做一个内部管理系统,还是给网站加上站内搜索,这套方案都能直接参考。适合那些不想被复杂技术栈绑架、又想让搜索体验上一个台阶的团队。
2. 全文检索核心原理解读
2.1 从 tsvector 到 tsquery:数据是怎么被索引的
想理解 PostgreSQL 全文检索,就必须先理解两个基础数据类型:tsvector 和 tsquery。这两个类型一个负责描述“文档里有什么词”,一个负责描述“我们要找什么词”,检索的过程就是让这两者做匹配。
tsvector 本质上是文档的词汇表示。比如我们有一句话“PostgreSQL 是强大的开源数据库”,经过分词处理之后,PostgreSQL 会把它转换成类似下面这样的结构:
'postgresql':1 '强大':3 '开源':4 '数据':5 '库':6 '是':2每个词后面跟的数字是它在原始文档中的位置,这叫 lexeme 位置信息。存储位置信息的好处是支持短语搜索,比如“开源数据库”这种连续词组的精确匹配,就是通过位置能够验证实现的。如果只是简单的“哪些词在这个文档里出现过”,是不需要这么多信息的,但加上位置之后,能做的查询模式就丰富了很多。
tsquery 则是查询端的数据类型。比如用户搜索“开源数据库”,PostgreSQL 会把它解析成:
'开源' & '数据库'中间的 & 表示 AND 关系,也就是说文档里必须同时包含这两个词才算命中。如果是多个搜索词,可以组合出 ||(OR)、!(NOT)以及 <->(短语跟随)等复杂逻辑。
实际执行检索时, PostgreSQL 不是拿 tsquery 去一行一行比对 tsvector,而是借助一个叫做 to_tsvector 的函数在插入或更新时就把文档转换成 tsvector,然后交给 GIN 索引去组织。GIN 索引在处理“一个文档对应多个词”这种倒排结构时特别高效。它本质上是把每个词映射到一批包含这个词的文档 ID 列表,查询时只需要快速定位词的倒排列表,再求交集或并集,而不是线性扫描全表。这个原理和搜索引擎框架中的倒排索引实际上是一个思路,只是实现深度和规模上限不同。
2.2 GIN 索引为什么适合全文检索
GIN 是 Generalized Inverted Index 的缩写,通用倒排索引。数据库界对它的评价非常直接:设计目标就是处理全文检索、数组、JSONB 这类“一个值包含多个元素”的场景。
对于全文检索来说,GIN 索引存储的结构大致是:
- 每个独立的 lexeme 作为键
- 每个键对应一个 posting list,记录哪些行的 tsvector 包含这个词
- posting list 里还会带一些位置信息,用于做短语匹配和排序
查询比如 tsquery 包含两个词并需要交集时,GIN 索引会用位图或指针的方式快速合并两个 posting list,再用位置信息做进一步的精确过滤。整个过程避开了绝大多数无关数据块的扫描,性能因此有了质的保证。
相比 B-tree 索引,GIN 索引有两个需要注意的地方。第一是写入代价更高,因为插入一行数据可能要更新多个词条的 posting list;第二是索引体积通常更大。但全文检索场景下,写入频率远低于查询频率,写入慢一点完全可以接受,而查询速度的提升是实打实的收益。
还有一个细节很多人不知道:PostgreSQL 的 GIN 索引支持 fastupdate,原理是先写进一个待处理缓冲区,积累到一定规模再批量合并到主索引。这样可以减少随机写,但代价是查询时需要额外扫描底部的待处理列表。如果系统对查询的实时一致性要求极高,可以考虑用 gin_fastupdate = off 来关闭它,换得更稳定的查询性能。
2.3 理解文本检索配置(Text Search Configuration)
PostgreSQL 的全文检索不是一套死板的逻辑,它的核心设计是“可配置”。一个完整的文本检索配置定义了四件事:怎么把文本拆成词(分词器)、怎么把词归一化(字典处理)、哪些词不值得索引(停用词)、以及最终怎么把词转成 lexeme。
系统默认配置通常比较适合英文,它会按空格和标点切分单词,再做大小写折叠。到了中文场景,默认配置几乎完全失效,因为中文词与词之间没有天然的空格分隔,必须依赖专门的分词器。PostgreSQL 社区里有不少中文分词插件,比如 zhparser、pg_jieba、scws 等,它们做的事情本质上都是把一段连续的中文文本切成合理的词语序列。
配置的选择直接决定了检索效果的成败。举个很简单的例子,“南京市长江大桥”这句话,如果分词器不够好,可能被切成“南京”“市长”“江大桥”,用户搜“南京”能命中,搜“长江大桥”就倒霉了。而一个好的分词方案会优先识别长词,切出“南京市”“长江大桥”,用户体验完全不同。后面我会专门用一节来讲中文分词器的搭建和调优。
3. 场景落地:从零搭建一套全文检索服务
3.1 准备工作:环境版本与基础表结构
先说下我这次用来演示的环境搭配:PostgreSQL 15,操作系统是普通的 Linux 服务器,内存 8G,磁盘是 SSD。这个配置很常见,也是我线上项目的真实缩影。版本选择上我推荐至少 PostgreSQL 12 以上,因为从 12 开始,GIN 索引的稳定性、分词器的扩展接口、以及相关的性能优化都有明显改进。
假设我们要做一个商品管理系统,里面有一张商品表,包含商品名称和商品描述。表结构可以这样建:
CREATE TABLE products ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, created_at TIMESTAMP DEFAULT now() );业务需求是最朴素的:用户输入一个关键词,我们要快速找到名称或描述里包含该关键词的商品,并且按相关度排序返回。这种需求在电商后台、内容管理系统里太常见了,就拿来作为我们一步步展开的基础场景。
先把几条测试数据插进去:
INSERT INTO products (name, description) VALUES ('无线蓝牙耳机', '支持主动降噪,续航长达三十小时,适合通勤使用'), ('机械键盘', '青轴手感,RGB背光,适合程序员和游戏玩家'), ('智能手表', '支持心率监测,多种运动模式,防水等级IP68');注意这里的中文文本都是连续不断的字符串,没有任何特殊分隔符。这正是中文全文检索和英文最大的差异点,也是我们后续要重点解决的地方。
3.2 快速体验默认配置下的全文检索
在中国用户遇到中文分词问题之前,先让我们用默认配置跑通整个流程,感受一下 PostgreSQL 原生全文检索的手感。
第一步,先把文本转换成 tsvector:
SELECT to_tsvector('english', 'PostgreSQL is a powerful open-source database');返回的 tsvector 会正确识别出各个英文单词,并去掉 is、a 这类停用词。
然后我们创建一个 GIN 索引来加速检索。对 products 表来说,最直接的做法是创建一个表达式索引,把 name 和 description 两个字段拼接后建立 tsvector 索引:
CREATE INDEX products_fts_idx ON products USING GIN ( to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(description, '')) );这里用 simple 配置而不是 english,是因为我们暂时想保留所有词,不做停用词过滤,方便观察分词结果。后面切到中文配置时,只要把配置名换成 zhparser 就完成了。
查询的时候用 to_tsquery 把用户输入转成查询对象:
SELECT id, name, description FROM products WHERE to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(description, '')) @@ to_tsquery('simple', '程序员 & 键盘');这条查询会把用户输入的“程序员 键盘”解析成两个词并要求同时命中。走 GIN 索引后,执行计划会显示 Index Scan using products_fts_idx,而不是 Seq Scan,性能分水岭就在这里。
3.3 加一个版本字段保留完整文本
刚才的表达式索引有个隐患:它把 to_tsvector 的结果作为表达式存进了索引,如果只靠索引本身,是拿不回原始文本的。在真实的查询请求里,我们通常还需要返回商品完整名称和描述,那就必须在查询时访问表数据。如果优化器选择 Index Only Scan,反而会因为要回表而多出额外开销。
更推荐的做法是增加一个独立的 tsvector 字段,专门存储预计算的向量。这样插入数据时负责分词,查询时直接拿索引字段和服务端生成的 tsquery 匹配。
ALTER TABLE products ADD COLUMN fts tsvector; UPDATE products SET fts = to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(description, '')); CREATE INDEX products_fts_idx ON products USING GIN (fts);之后每次插入或更新商品时,用触发器或者应用侧代码同步维护 fts 字段。
CREATE FUNCTION products_trigger() RETURNS trigger AS $$ BEGIN NEW.fts := to_tsvector('simple', coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '')); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER products_fts_update BEFORE INSERT OR UPDATE ON products FOR EACH ROW EXECUTE FUNCTION products_trigger();触发器的存在让业务侧代码不需要感知分词细节,不管是什么语言写的应用,只要正常执行 SQL 插入和更新,fts 字段就会自动更新。
这里想提一个经验之谈:在生产环境里,触发器确实可以保证一致性,但如果批量导入数据量很大,触发器的逐行开销会比较明显。我实际操作中会在大批量导入前先禁用触发器,导入完成后再统一 UPDATE 一次 fts 字段,最后重新创建索引,性能会好非常多。
4. 中文分词器选型与配置进阶
4.1 为什么要对中文单独处理
中文文本和英文有一个根本性的差异:英文单词天然以空格分隔,分词器只需要按空白符和标点切开再小写化即可;中文词之间没有明显的分隔边界,必须由算法判断哪里是词边界。如果按单个汉字来索引,用户搜“冰箱”就匹配不到“冷藏”,“电竞”和“游戏”也完全拉不上关系,召回率会非常难看。
针对中文场景,社区里用得比较多的方案是 zhparser。它基于分词内核,支持多种分词模式,并且能无缝接入 PostgreSQL 的全文检索配置体系。装上之后,你不需要改表结构、改 SQL 语法,只需要创建对应的 text search configuration,然后把之前建索引的配置名从 simple 换成新配置即可。
zhparser 的安装方式根据操作系统不同有差异,常见的是通过包管理器安装或者源码编译。以 PostgreSQL 15 + Linux 为例,源码编译的大致流程是安装依赖、git clone 源码、编译并拷贝 .so 文件与控制文件到 PostgreSQL 的 lib 和 extension 目录,最后在数据库里执行 CREATE EXTENSION。
4.2 配置 zhparser 的关键参数
zhparser 分词效果好坏,很大程度上取决于扩展配置里的几个参数。安装完扩展后,第一步先声明扩展:
CREATE EXTENSION zhparser;然后创建自定义的全文检索配置,并指定分词器为 zhparser:
CREATE TEXT SEARCH CONFIGURATION zhcfg (PARSER = zhparser);接下来是重头戏:设置分词模式。zhparser 支持多种模式,比如 n(名词)、v(动词)、a(形容词)等。实际使用中,我强烈建议把所有词性都打开,不要自作聪明地只索引名词:
ALTER TEXT SEARCH CONFIGURATION zhcfg ADD MAPPING FOR n,v,a,i,e,l WITH simple;注意这里 MAPPING 的目标是 simple 字典,作用是让 zhparser 切出的每个词都直接转成 lexeme,不要再做额外的变形处理。中文很少需要词干化,simple 是最合适的选择。
还有一个经验值:zhparser 的分词内部有多粒度选项,比如“细粒度模式”和“粗粒度模式”。粗粒度模式更倾向于切出长词,适合商品名称、标题这类短语;细粒度模式会切出更多短词,适合长文章正文。如果你的业务同时包含短标题和长描述,可以用 multi_short 这样的参数让两种模式的结果都保留,提升召回率,代价是索引体积变大。我通常在混合内容场景下开启:
zhparser.multi_short = true这个参数可以在数据库会话级别动态设置,也可以在 postgresql.conf 里全局设置。设置后需要重建索引才能生效。
4.3 自定义词典:行业术语的救星
无论 zhparser 分词内核多好,总会遇到它不认识的专业词汇,比如某个产品系列的特定型号、术语缩写、品牌名称。这种词的切分错误会直接导致搜索漏掉关键结果。
解决办法是给分词器补充自定义词典。zhparser 支持外部词典文件,通常路径由参数 zhparser.extra_dicts 指定。词典文件格式很简单,每行一个词,比如:
电竞显示器 人体工学椅 氮化镓充电器修改配置后,需要重启 PostgreSQL 进程使词典生效。在这里提醒一下,词典的生效机制是启动时加载,不是即时生效,别改了半天怎么分词结果不变,然后才想起来没重启。
词典内容不能太激进。我之前试过把一个品牌的完整名称加进词典,结果分词器死活不肯拆开它,导致用户只搜品牌简称时什么都搜不到。正确的做法是加那些“合起来是词、拆开就变味”的复合词,而不是把所有业务关键字都一股脑塞进去。
4.4 配置完后如何验证分词效果
这一步很多人会忽略,但恰恰是最该先做的。配置调整完,别急着去查业务表,先单独验证分词器的输出:
SELECT * FROM ts_parse('zhparser', '南京市长江大桥'); SELECT to_tsvector('zhcfg', '南京市长江大桥');第一条语句能看到分词器的原始输出,包括每个词的起始位置、词性标记;第二条语句能看到最终进入索引的 tsvector。如果你发现“长江大桥”在这个结果里是作为一个整体出现的,恭喜你,配置生效了。如果看到的是“长江”“大桥”分开,就要考虑词典或者多粒度参数的问题。
这种验证方式把分词问题和查询问题解耦了,排查效率会提高很多。我每次改完分词配置,都会先跑这组语句,确认结果对了再去动业务数据。
5. 精细排序:让最相关的搜索结果排在最前面
5.1 基础相关度打分逻辑
能搜出来结果只是第一步,用户真正在意的是“搜出来的东西是不是我想要的”。PostgreSQL 全文检索提供了一个专门用于排序的函数 ts_rank,它不是简单的命中次数统计,而是综合考虑了词的稀有度、词在文档中的位置、命中密度等若干因素。
具体来说,ts_rank 会参照一组权重,默认情况下在标题中命中的权重高于在正文中命中的权重。如果我们把商品名称和描述分别赋予不同的权重,排序结果会更有意义。PostgreSQL 支持用 setweight 函数为 tsvector 的词汇位置标记权重。
我们的商品表可以改成这样:
ALTER TABLE products ADD COLUMN fts_title tsvector; ALTER TABLE products ADD COLUMN fts_body tsvector; UPDATE products SET fts_title = setweight(to_tsvector('zhcfg', name), 'A'), fts_body = setweight(to_tsvector('zhcfg', description), 'B');A 权重最高,B 次之。查询时把两个向量合并:
SELECT id, name, ts_rank(setweight(fts_title, 'A') || setweight(fts_body, 'B'), query) AS rank FROM products, to_tsquery('zhcfg', '键盘') query WHERE setweight(fts_title, 'A') || setweight(fts_body, 'B') @@ query ORDER BY rank DESC;这样一来,名称中命中关键词的商品,排名会天然压过正文中命中关键词的商品。这个思路在所有站内搜索系统里都适用:字段的语义重要性,应该显式地反映到排序权重中。
5.2 一个实用的复合排序策略
纯靠 ts_rank 有时还是会翻车,例如一条只提到两次关键词的短标题商品,会比正文里反复提到关键词的详细商品得分更高。这在部分场景下没问题,但电商和内容搜索里,用户往往更看重信息的完整度。
我个人的做法是把 ts_rank 和基础字段排序组合起来,比如:
ORDER BY ts_rank(...) * 0.7 + log(1 + comment_count) * 0.3 DESC这样搜索结果不至于完全被单一维度绑死。也可以用 ts_rank_cd 替换 ts_rank,它在计算覆盖密度时对短语聚集的文档更友好,想强调“精确片段连续出现”的场景可以试它。
排序规则没有绝对标准,完全取决于业务希望你优先展示什么。关键是把这些函数用组合的方式放进 ORDER BY,而不是拘泥于某一个默认行为。
5.3 高亮显示:不要自己拼字符串
搜索结果页总免不了要在关键词周围加高亮。PostgreSQL 提供了 ts_headline 函数,能直接在数据库层完成结果的片段提取与关键词高亮标记。
SELECT ts_headline('zhcfg', description, query, 'StartSel = <b>, StopSel = </b>') AS highlighted FROM products, to_tsquery('zhcfg', '降噪') query WHERE ...;它能从长文本中抽取包含关键词的那一小段上下文,而不是把整段内容甩给前端。需要注意 ts_headline 默认输出的片段和 em 标签格式是给纯文本用的,实际前端渲染时可以根据自己的需要调整 StartSel 和 StopSel 的取值。
高亮是在数据库中计算还是在前端计算,很多人纠结。我的经验是:如果记录条目不多,放在数据库里很省事;如果一次要展示几十上百条结果,建议只在数据库生成片段中的关键词位置信息,由前端做渲染,避免大 SQL 结果集拖慢应用响应。
6. 常见问题与排查技巧实录
6.1 为什么查询不走索引
这是被问烂了的问题。最常见的诱因是查询时隐式转换导致表达式不匹配。我们建索引时用的是 to_tsvector('zhcfg', name),查询时如果写成 to_tsvector('simple', name) 或者直接用 fts 字段比较,索引就无法匹配。请确保建索引的表达方式和查询表达式完全一致,配置名完全相同。
另一个典型原因是查询条件里多了一层包裹。比如在 WHERE 条件外面套了函数,导致优化器无法把表达式下推。解决办法是检查执行计划,确认看到 Index Scan 而不是 Bitmap Heap Scan 全表扫描。
调试执行计划的标准姿势:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM products WHERE fts @@ to_tsquery('zhcfg', '键盘');如果计划中出现了 Seq Scan,先冷静查表达式一致性;没有发现问题,再考虑是不是数据量太小,优化器认为全表扫描更便宜。此时可以临时调低随机页代价或者直接设置 enable_seqscan = off 来验证索引是否有效。
6.2 tsquery 里的特殊字符转义
用户输入的关键词千奇百怪,直接拼进 to_tsquery 会把查询语法弄炸。比如用户搜索“C++”,默认会被解析成 C & &,后面的 & 直接报语法错误。
解法是用 plainto_tsquery 替代 to_tsquery。直接输入原始文本,它会自动分词并按 AND 逻辑组合,用户输入永远是纯文本,根本不需要关心语法。
SELECT * FROM products WHERE fts @@ plainto_tsquery('zhcfg', 'C++ 键盘');需要更复杂的 AND OR 组合逻辑时,可以用 websearch_to_tsquery,它兼容搜索引擎的写法风格,比如用引号表示短语。我的建议很明确:对外部用户输入永远不要直接使用 to_tsquery,它只适合内部已知格式的关键字。
6.3 索引膨胀与更新性能恶化
GIN 索引的插入成本相对较高,频繁更新商品信息时,索引的待处理页面会积累得很快。长期运行后,索引膨胀会拖慢所有写入操作,甚至影响查询。
对策之一是 index vacuum:
- 定期执行 VACUUM products
- 定期 REINDEX INDEX products_fts_idx
- 如果业务能接受短暂的查询停顿,VACUUM FULL 是终极方案
还有一个实际经验:在批量导入前先 DROP 索引,导入完成后再 CREATE 索引,比带着索引一条条插入快 3 到 5 倍。数据导入完成后一次性建 GIN 索引,构建速度远高于逐行维护。
6.4 中文检索结果空的排查步骤
中文检索搜不到结果,除了分词器配置问题,还有一个非常隐蔽的大坑:客户端字符集和服务端不一致。连接参数里没有指定 client_encoding,中文文字可能被乱码化,分词器自然一个都切不对。
排查步骤固定如下:
- 先 SHOW client_encoding,确定是不是 UTF8
- 再执行 to_tsvector('zhcfg', '任意中文'),看 tsvector 里是不是正常的中文词
- 如果 tsvector 里全是乱码,修正连接字符串的编码参数
- 如果 tsvector 正常但查不到,检查查询端用的配置名是否与索引配置名一致
这个过程基本能覆盖九成以上“中文搜不到”的问题。剩下的一成往往是停用词表误伤,看看自己设置的配置里的停用词配置是否把高频词过滤得太狠了。
6.5 性能基线:一个千万级数据的参考
我在一个模拟了 800 万条商品描述的实验环境里测过,单机 PostgreSQL 15,8G 内存,SSD 磁盘,tsvector 字段加 GIN 索引,随机查一个双词组合,返回结果集在 500 条以内时,查询延迟稳定在 30 到 80 毫秒之间。如果结果集膨胀到上万条,延迟会超过 150 毫秒,但那是排序和回表的开销。
这个量级的数据下,GIN 索引本身的表现非常出色。等到数据量再上一个台阶,比如跨到上亿条、或者需要支持每秒上千次并发搜索,倒是可以考虑引入分片、外部搜索引擎或者物化视图来分担压力。但在那之前,PostgreSQL 全文检索完全扛得住。
所以我对团队的嘱咐往往是:先别想着搭建搜索引擎框架,先把手头 PostgreSQL 的全文检索玩明白,它解决你 90% 的问题只需要你 10% 的运维成本。
7. 结语:关于全文检索的一些个人体会
做全文检索这几年,我最大的感触是:工具的价值不在于它有多炫酷,而在于它能把复杂问题的复杂度藏到哪里。PostgreSQL 全文检索把分词、索引、排序、高亮全都整合进了 SQL 生态,让应用层不需要为搜索框单独养一支团队,也不需要为“同步什么时候到”、“两边数据一不一致”而焦虑。
当然,它也有边界。多表联合检索、跨地域高可用、海量日志检索这种场景,PostgreSQL 并不是天然最优解。但选型不是比参数大小,是比“你的团队能长期稳定维护什么”。如果你需要一个开箱即用、和业务深度耦合的站内搜索,先把 PostgreSQL 的全文检索吃透,大概率不会让你失望。
最后分享一个小技巧:给全文检索的 tsvector 字段建立索引后,在应用层的 SQL 里把用户输入统一走 plainto_tsquery 做纯文本转换,同时给业务表的选择性排序字段预留权重组合的位置,这套底子在大多数中小项目里可以用很久,而且后续再怎么迭代,都不会被早期写的检索逻辑拖后腿。