想象你管理着一座藏书十万册的图书馆。某天读者来问:“有没有讲数据库性能的书?”新手管理员怎么办呢?他只能走到每一排书架前,一本一本地翻开目录页,翻完十万本,才敢告诉你答案——这就像数据库里的 LIKE '%关键词%' 全表扫描,慢得让人绝望。可一位老管理员会直接走向大厅角落的检索目录柜:柜子里每个关键词都对应一张卡片,卡片上清清楚楚地写着哪些书、在第几页提到过它。一分钟不到,答案就出来了。PostgreSQL 全文搜索要做的,正是给数据库这座图书馆装上这样一座“检索目录柜”。
这个柜子由四件套组成:tsvector 是编目员写下的目录卡,tsquery 是读者填写的查询单,GIN 索引是存放卡片的大柜子,中文分词则是专门处理中文书籍的编目规则。把这四件套装配起来,你不需要搬来 Elasticsearch 那套重型设备,也能在数据库里完成中等规模的搜索。这篇文章就用“图书馆”这个比喻,带你从原理到实践,完整走一遍 PostgreSQL 全文搜索的搭建过程。
对后端开发者而言,掌握 PG 全文搜索意味着多一套“轻量搜索”的武器库:处理百万级数据的中小型项目不必引入 ES;同时能更清醒地判断何时该升级到专用搜索引擎,避免架构过度设计。
目录
可视化演示
动态演示PostgreSQL全文搜索的核心机制,通过输入/处理/存储/输出四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
PostgreSQL全文搜索:Post PostgreSQL全文搜索核 tsvector文档表示与ts 核心机制可视化 PostgreSQL全文搜索整体架构与关键组件
搜索原理:从翻书到查目录
老管理员找书,靠的不是把每本书翻一遍,而是大厅角落那排抽屉柜。柜子里插满卡片,每张卡片顶端写着一个关键词,下面密密麻麻排着书号。想找“数据库”相关的书,抽出“数据库”这张卡片,书号直接出现在眼前。这套机制的学名叫倒排索引——正常索引是“书 → 关键词”的正向关系,而倒排索引把方向反过来,建成“关键词 → 书籍列表”,正好匹配“按内容找书”的检索场景。
PostgreSQL 的全文搜索正是围绕倒排索引设计的。当你用 LIKE '%数据库%' 搜索时,数据库必须逐行扫描、逐行做字符串匹配,就像新手管理员翻书;而全文搜索先把每篇文档拆解成词条(这个过程叫词项归一化:转小写、去停用词、还原词根),再将这些词条连同文档编号一起存入倒排索引,查询时只需查找索引。
为了让结果更聪明,搜索引擎还引入了相关度打分:一篇文档里关键词出现得越多、这个词在全库越罕见,这篇文档就越“相关”。这就是 TF-IDF 的核心思想:
目录柜的想法很朴素,但正是这套“关键词 → 书号”的反向映射,让全文搜索比翻书快上几个数量级。接下来我们看 PostgreSQL 如何把一本书变成一张目录卡——这就是 tsvector。
- 词频 tf(t,d)统计词条 t 在文档 d 里出现的次数。一篇文章里“数据库”出现十次,比出现一次更可能是在讲数据库。
- 逆文档频率 idf(t)用文档总数 N 除以包含词 t 的文档数再取对数。“数据库”只出现在 100 篇里,比出现在 90000 篇里的“的”更能区分主题。
- 综合权重两者相乘,得到词条在文档中的最终权重——既看局部出现密度,也看全局稀有程度。
tsvector 词向量:编目员写下的目录卡
老管理员从不整本抄书。他拿过一本书,翻一翻,提炼出几十个关键词,每个词后面标注它出现在第几页,再按重要程度贴上不同颜色的标签。PostgreSQL 里的 tsvector 就是这张目录卡:把一整篇文本压缩成“词素 + 位置 + 权重”的集合,体积小、检索快。
创建目录卡的工具是 to_tsvector 函数。它会先调用分词器把文本拆成词,再做归一化(英文转小写、去词尾变化),最后记录每个词在原文里的位置。看一个例子:
注意 jumps 变成了 jump,lazy 变成了 lazi,这就是词根还原。每个词后面的数字是位置编号,而权重(A/B/C/D)可以后续手动指定,比如标题里的词标为 A,正文里的词标为 D。数学上,tsvector 就是一组三元组的集合:
目录卡建好了,读者要怎样描述需求呢?这就轮到 tsquery 登场。
- 词素 l词经过归一化后的标准形态,不同写法在这里汇成同一个词条。录卡时“running“和”ran“会统一成”run”。
- 位置 p记录词在文档中第几个出现,短语匹配和距离运算都靠它知道“高性能”和“数据库”是否紧挨着。
- 权重 wA/B/C/D 四个等级,A 最重、D 最轻。你可以在
tsvector构造时给不同字段赋不同权重,影响排序。
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');-- 结果: 'fox':4 'jump':5 'lazi':8 'quick':2
tsquery 查询:读者填写的查询单
读者在查询单上写:“我要数据库 和 性能相关的书,但 不要 讲缓存的。”管理员一看就能明白:取“数据库”和“性能”两张卡片的交集,再排除“缓存”卡片上的书。tsquery 就是这张结构化查询单,它支持 &(同时满足,即 AND)、|(任一满足,即 OR)、!(排除,即 NOT)三种逻辑运算。
构造查询用 to_tsquery,它和 to_tsvector 使用同一个分词器,保证查询词和文档词用同一套规则归一化:
to_tsquery 还支持两种进阶写法:用 <-> 表示“两个词必须相邻出现”(短语匹配),如 高性能 <-> 数据库;用 :* 表示前缀匹配,如 数据:* 能命中“数据”“数据库”“数据结构”。查询的匹配条件本质上是一个布尔表达式:
有了查询单,图书馆里的目录柜得够快才行。GIN 索引就是这个“快”的关键。
- 查询词素 l_i查询词先经过与文档相同的分词归一化,保证“查询单语言”和“目录卡语言”一致,否则两边对不上。
- 与运算 ∧要求文档同时包含两个词,对应
&符号。需求越限越严,结果越精准。 - 或运算 ∨包含任意一个词即可,对应
|符号,扩大召回范围。 - 非运算 ¬排除包含某词的文档,对应
!符号,用于过滤干扰项。
SELECT to_tsquery('english', 'postgres & (performance | index)');-- 结果: 'postgres' & ( 'perform' | 'index' )
GIN 索引:装卡片的大柜子
目录卡片如果随意堆在桌上,找起来一样慢。PostgreSQL 用 GIN(Generalized Inverted Index) 索引来存放 tsvector 中的词条。它的内部结构是:每个词条对应一个倒排表,表中记录哪些文档包含这个词。查询时只需要两步:先找到词条,再取出这个文档列表。
GIN 基于 B-tree 组织词条,所以定位词条的复杂度是 O(log n);随后取文档列表的耗时正比于命中文档数。于是整体查询代价是:
创建 GIN 索引很简单,但要注意:通常先把文本转成独立的 tsvector 列,再对该列建索引,避免每次查询都现场转换:
GIN 的代价是“写放大”:每插入一篇文章,要更新多个词条的倒排表,写入比普通 B-tree 索引更慢。查询快、写入慢,这就是目录柜的保养费。中文书籍进馆时还有一个特殊问题——中文没有空格,编目员怎么切词?
- 倒排表 PL(l)词条 l 对应的文档编号集合,建索引时由
tsvector自动生成。 - 词条定位 O(log|L|)GIN 内部用 B-tree 组织所有不重复词条,查找一个词只需走树高,而非扫全表。
- 结果回表 O(k)拿到文档编号列表后,回原表取出整行数据,耗时与命中数 k 线性相关。
ALTER TABLE articles ADD COLUMN body_tsv tsvector;UPDATE articles SET body_tsv = to_tsvector('english', body);CREATE INDEX idx_gin_body ON articles USING GIN (body_tsv);
中文分词:给中文书籍编目
英文图书的词汇天然被空格分开,编目员照抄即可。但中文的一句“研究生命科学”可以切出“研究/生命/科学”,也可能被切出“研究生/命/科学”,含义天差地别。PostgreSQL 默认的分词器只认识空格和标点,面对连续汉字会不知所措,所以中文搜索必须引入专门的分词插件。
主流的两个方案是 zhparser(基于 SCWS 分词引擎)和 pg_jieba(结巴分词的 PG 移植)。它们的原理类似:在预置词典的基础上,用统计语言模型挑选概率最大的切分方式。给定句子 S,分词器在所有可能的切分中寻找累计概率最高的一种:
实际配置时,装好扩展后需要创建名为 zhparser 或 jieba 的全文搜索配置,并在每次建 tsvector 时指定它。整套配置只在会话内生效,重启数据库后仍需设置 search_path——这也是后面误区部分要提醒的重点。切好词、建好目录卡,最后一个问题浮出水面:命中的十本书里,哪本最值得读?
- 候选切分 S一句话往往有多个合法切分,如“数据库/性能”或“数据/库/性能”都是候选。
- 词概率 P(w_i)每个词在大量语料中出现的频率,常见词如“数据”概率远高于生僻组合。
- 最优切分 S\*遍历所有候选切分,把词概率连乘,乘积最大的就是最自然的分法。
排序调优:按推荐度排书架
当“数据库 + 性能”命中三十本书时,管理员不能把这三十本一股脑堆给读者,他得按推荐度排个序:整本专讲数据库性能的书排最前,只在第一章提到一句的排最后。PostgreSQL 用 ts_rank 函数给每篇命中文档打分,分高者排在前面。
打分的依据有两个:一是词条在文档里的权重等级(A 比 D 贡献大),二是词条在文档中的出现频次。setweight 可以给已构造的 tsvector 批量赋权,比如标题字段设为 A、正文字段设为 D:
一个常见技巧是用 COALESCE 合并多个向量:setweight(to_tsvector(title),'A') || setweight(to_tsvector(body),'D'),这样标题命中的文档自然排在正文命中的前面。简化后的排序模型是:
排序规则明确后,全文搜索的整套工具链就齐了。但还有一个问题藏在幕后:这套方案到底适合什么规模?什么时候该把书搬进更大的图书馆?
- 命中词条 t ∈ q只有查询中出现、且文档里也存在的词条参与计分,两者缺一不计。
- 权重 weight(t)A/B/C/D 等级的数值化体现,A 级命中贡献远大于 D 级,是调优的主要旋钮。
- 频次 occ(t,d)词条在文档中出现的次数,出现越多说明词条与文档主题关联越紧密。
- 累加求和 Σ把每个命中词条的贡献相加,得到文档的总相关度。
SELECT title, ts_rank(body_tsv, query) AS rankFROM articles, to_tsquery('english', 'database & performance') AS queryWHERE body_tsv @@ queryORDER BY rank DESC;
实践应用:何时换大设备
社区图书馆藏书一万本,配一个抽屉目录柜绰绰有余;可如果藏书千万册、高峰时段每分钟来一千个读者,手抄卡片柜就撑不住了——该上自动化检索系统了。PostgreSQL 全文搜索与 Elasticsearch 的分界线,本质上是数据量与并发量的分界线。
经验上,百万行以内的数据、峰值百 QPS 以内的查询,PG 全文搜索完全能胜任:它不需要引入额外服务,支持事务,数据一致性有保障。而 Elasticsearch 适合海量数据、高并发、需要复杂聚合分析、滚动搜索等场景。判断是否升级,可以套用一个粗略的决策式:
无论是否升级,PG 内部的优化都不可少:定期 VACUUM 控制 GIN 索引膨胀、把 tsvector 独立成列避免重复计算、用 EXPLAIN ANALYZE 确认索引真的被使用。理解了这些边界,你就能像那位老管理员一样——小图书馆用抽屉柜,大图书馆用自动化系统,心里始终有本明白账。
- 文档量 N_docs可被搜索的文本行总数。GIN 索引能支撑百万级,再往上索引膨胀和维护成本会陡增。
- 峰值 QPS业务高峰期的每秒查询次数,超过百级后 PG 的排序开销和连接数可能成为瓶颈。
- 布尔决策两个条件任意满足,就值得引入专用搜索引擎;否则继续用 PG,把架构保持轻量。
AI 应用场景
内容管理系统的站内搜索
阶段:数据存储与检索层优化
解决的问题:传统 CMS 的站内搜索用 LIKE '%关键词%',数据量过万后响应延迟飙升至秒级,且无法按相关度排序。PostgreSQL 全文搜索将文章标题与正文分别转成 tsvector,通过 GIN 索引将查询时间压缩到毫秒级,还能用 setweight 让标题命中排在正文命中之前。
具体案例:某技术博客平台有 3 万篇文章,读者在搜索框输入“数据库索引优化”,系统用 to_tsquery('zhparser', '数据库 & 索引') 匹配,结合 ts_rank 排序后,把最相关的五篇文章呈现在第一页。整个过程无需引入 ES,架构保持单库。
中小型电商的商品搜索
阶段:特征匹配与排序服务
解决的问题:电商商品名和描述是典型的海量短文本,传统方案要么用数据库 LIKE 硬扛,要么过早引入 ES 增加运维负担。PG 全文搜索支持分词、逻辑组合、前缀匹配,足以支撑商品名检索和标签筛选组合查询。
具体案例:某跨境电商平台有 50 万商品,运营希望用户搜“无线 耳机”能同时命中“无线蓝牙耳机”和“头戴式无线耳机”。通过 pg_jieba 分词 + GIN 索引 + 权重排序,平台在守住 100 QPS 峰值的前提下,保持了 200ms 以内的搜索响应,且无需维护额外搜索集群。
Python 演示
"""模拟 PostgreSQL 全文搜索:构建倒排索引并执行查询。"""import mathimport re STOPWORDS = {"de", "le", "et", "en", "un", "une"} def tokenize(text): """简易分词:英文按单词,中文按单字 + 相邻二元组。""" text = text.lower() latin = re.findall(r"[a-z0-9]+", text) han = re.findall(r"[\u4e00-\u9fff]", text) bigrams = [han[i] + han[i + 1] for i in range(len(han) - 1)] words = [w for w in latin if w not in STOPWORDS] + han + bigrams return words def build_index(docs): """返回倒排索引:term -> {doc_id: [positions]}。""" index = {} for doc_id, doc in enumerate(docs): terms = tokenize(doc["content"]) for pos, term in enumerate(terms): if term not in index: index[term] = {} if doc_id not in index[term]: index[term][doc_id] = [] index[term][doc_id].append(pos) return index def search(query, docs, index, top_k=3): """执行布尔查询,返回 (doc_id, score) 列表。""" q_terms = set(tokenize(query)) scores = {} for term in q_terms: if term not in index: continue entries = index[term] idf = math.log(len(docs) / (len(entries) + 1)) + 1 for doc_id, positions in entries.items(): scores[doc_id] = scores.get(doc_id, 0) + idf * len(positions) ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [(doc_id, round(score, 3)) for doc_id, score in ranked[:top_k]] if __name__ == "__main__": docs = [ {"title": "PostgreSQL 优化指南", "content": "数据库 索引 性能 优化 查询"}, {"title": "全文搜索原理", "content": "全文 检索 倒排 索引 数据库"}, {"title": "Redis 缓存实践", "content": "缓存 性能 数据库 并发"}, {"title": "搜索引擎设计", "content": "全文 搜索 索引 排序"}, {"title": "GIN 索引原理", "content": "索引 数据库 搜索 性能"}, {"title": "中文分词技术", "content": "分词 中文 全文 检索"}, ] index = build_index(docs) print("倒排索引片段:", {k: v for k, v in list(index.items())[:3]}) for doc_id, score in search("数据库 索引", docs, index): print("命中:", docs[doc_id]["title"], "score =", score)
latin = re.findall(r"[a-z0-9]+", text)先抽出英文单词和数字,模拟英文分词。bigrams = [han[i] + han[i + 1] for i in range(len(han) - 1)]中文按相邻两字组合成词,模拟简单二元分词。index[term][doc_id].append(pos)这就是在搭建倒排表:每个词条记录它出现在哪些文档、哪些位置。idf = math.log(len(docs) / (len(entries) + 1)) + 1计算逆文档频率,词条越罕见权重越高。scores[doc_id] = scores.get(doc_id, 0) + idf * len(positions)累加每个词条的贡献,相当于本文的 ts_rank 简化版。ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)按分数降序排列,返回最相关的 Top-K 篇文档。
常见误区
误区“我明明建了 GIN 索引,为什么查询还是慢?是不是索引没生效?” 于是开始怀疑 PostgreSQL 的优化器。
正解绝大多数情况是查询写法不匹配索引。比如对原始 body 列用 to_tsvector(body) @@ query 写法时,由于索引建在独立列 body_tsv 上,表达式没有直接指向索引列,优化器无法使用索引。应该把 tsvector 存成独立列再建索引,或者用表达式索引。这就像目录柜里的卡片按“书名”排序,你却拿着“作者”去查,柜子当然帮不上忙。
为何常见:很多人把“建索引”等同于“查询自动加速”,忽略了索引必须与查询表达式对齐这一前提。
误区“都是搜关键词,'%数据库%' 和全文搜索差不多嘛,LIKE 还简单。” 在数据量小的时候确实感觉不到差别。
正解LIKE '%关键字%' 无法利用索引(除非使用 pg_trgm 扩展),每查一次都要全表扫描;更致命的是它不支持分词、不支持相关度排序,也无法处理“数据库 性能”这种多词逻辑组合。全文搜索和 LIKE 是两种工具:一个是专业的目录柜,一个是手工翻书,性能与能力都不在同一量级。
为何常见:小数据量下 LIKE 的慢被掩盖了,等到数据量增长才发现问题,此时改动成本已经很高。
误区“我安装并配置好 zhparser 了,当时查询没问题,怎么重启数据库后中文分词又不工作了?” 以为一劳永逸。
正解zhparser 创建的解析器和配置存储在数据库中,但 search_path 是会话级设置。重启后如果搜索路径里没有包含扩展所在的 schema(通常是 public 或专有 schema),查询就找不到分词配置。需要在每次连接时显式设置 search_path,或者在数据库级别统一配置,否则就像编目规则册被锁进柜子,别人不知道去哪查。
为何常见:把“会话配置”误当成“持久化配置”,忽略了 PostgreSQL 中 search_path 按会话生效的机制。
误区“GIN 索引是只读的加速结构,建好之后不用管,反正它会自动更新。” 于是从不维护,直到查询越来越慢。
正解GIN 索引会随插入、更新产生索引膨胀,删除的词条并不会立刻从倒排表中消失。需要定期 VACUUM 回收垃圾空间,必要时 REINDEX 重建索引。此外,大量并发插入时 GIN 有 pending list 机制,可能导致查询短暂变慢。索引和仓库里的目录柜一样,用久了要清理、要除尘。
为何常见:B-tree 索引自动维护得比较“省心”,让人误以为所有索引都无需人工关怀,忽略了 GIN 倒排结构的维护特性。
面试题
Q1. PostgreSQL 全文搜索最推荐使用哪种索引?
- A.B-tree 索引树
- B.GIN 倒排索引← 正确答案
- C.Hash 索引桶
- D.BRIN 索引页
答案:B。GIN 是专门为全文搜索设计的倒排索引,适合存放 tsvector 中的海量词条。B-tree 适合范围查询和等值查询,Hash 只能做等值匹配,BRIN 适合按物理顺序排列的大表。就像目录柜必须能快速按关键词找到书——GIN 正是这个抽屉柜。
Q2. to_tsvector('english', 'The Quick Foxes') 的词项归一化结果是?
- A.保留原词大小写复数
- B.全部小写并还原词形← 正确答案
- C.去掉所有字母只留空格
- D.切分后保持原样不动
答案:B。全文搜索要求文档词和查询词用同一套规则归一,The 变 the、Foxes 还原为 fox,否则查询时“Fox“和”foxes“会被当成两个词。这和图书馆编目时把”数据库“与”DataBase”归为同一主题是同一个道理。
Q3. tsquery 中符号 & 表示什么逻辑关系?
- A.表示逻辑与操作← 正确答案
- B.表示逻辑或操作
- C.表示逻辑非操作
- D.表示距离短语
答案:A。& 对应 AND,要求文档同时包含两侧词条;| 对应 OR,! 对应 NOT。用查询单来类比:& 就是“我既要有数据库,又要有性能”的并列需求,缺一不可。
Q4. GIN 索引相对顺序扫描的核心优势是?
- A.压缩索引占用空间
- B.避免写入锁冲突
- C.快速定位命中行← 正确答案
- D.支持所有数据类型
答案:C。GIN 把“关键词 → 文档列表”预先存好,查询时直接取出列表,无需逐行扫描。它并不压缩空间、不解决写锁问题,也并非所有类型都支持。就像老管理员直接翻目录卡,而不是一本一本翻书。
Q5. 中文全文搜索为何需要扩展分词器?
- A.默认分词器仅识别英文
- B.中文文本缺少空格分界← 正确答案
- C.PostgreSQL 不支持汉字
- D.中文不使用数据库存储
答案:B。PG 默认分词器按空格和标点切分,而中文词语之间没有天然分界,“数据库性能”需要被切成“数据库/性能”而非“数/据/库”才能被正确索引。分词扩展提供了词典和统计模型来解决这个问题。
Q6. 下列哪些属于全文检索的词项归一化处理?
- A.统一转为小写← 正确答案
- B.去除停用词汇← 正确答案
- C.还原词根形式← 正确答案
- D.建立回表查询
答案:ABC。归一化是把不同写法归一到统一词素:转小写让“PostgreSQL“和”postgresql“同义,去停用词去掉”的”“了”,还原词根让“running“归为”run”。回表查询是索引命中后的取数据阶段,不属于归一化。归一化就是编目规则的统一化,确保目录卡的写法保持一致。
Q7. ts_rank 相关性排序时主要考虑哪些因素?
- A.词频高低← 正确答案
- B.权重等级← 正确答案
- C.短语距离← 正确答案
- D.硬件配置
答案:ABC。ts_rank 综合词条出现频次、权重等级(A/B/C/D)和短语匹配情况来计算相关度。词频越高、权重越大、短语距离越短,排名越靠前。硬件配置只影响查询速度,不影响结果排序——就像推荐度取决于书的内容相关性,而不是书架材质。
Q8. tsquery 中哪个写法能匹配“高性能数据库”这种连续短语?
- A.高性能 & 数据库
- B.高性能 <-> 数据库← 正确答案
- C.高性能 | 数据库
- D.高性能!数据库
答案:B。<-> 表示两个词相邻出现,是短语匹配的核心语法;& 只要求同时出现,两词可能相隔很远;| 是任一满足;! 是排除。查询单上写明“高性能紧挨着数据库”,管理员才知道要找的是这个连续短语。
Q9. GIN 索引的维护会带来哪些成本?
- A.写入放大← 正确答案
- B.索引膨胀← 正确答案
- C.全表扫描
- D.顺序排序
答案:AB。GIN 在插入一行时要更新多个词条的倒排表,产生写入放大;删除和更新后旧词条仍占据空间,引发索引膨胀,需靠 VACUUM 和 REINDEX 治理。全表扫描是没索引时的做法,顺序排序是查询结果处理,都不是 GIN 的维护成本。
Q10. 关于 PG 全文搜索与 Elasticsearch 的取舍,哪项正确?
- A.任何规模都该用 ES
- B.PG 完全无法检索
- C.百万行内 PG 可胜任← 正确答案
- D.ES 不支持全文索引
答案:C。PostgreSQL 全文搜索在百万行数据、百 QPS 内性能足够且架构简单,不必引入 ES;ES 适合海量数据、高并发和复杂聚合场景。选项 A 过度设计,B 与事实不符,D 方向颠倒。判断标准就是那句老话:藏书少的社区图书馆,不必上自动化检索系统。