PlanetScale 客户最常询问的 Postgres 功能之一是全文搜索。今天我们宣布推出 TIN:一个快速、全功能且可靠的 Postgres 全文搜索扩展。TIN 全称 Text INdex,作为 GA 版本立即面向所有 Postgres 和 Neki 数据库提供。它支持布尔表达式、短语与跨度查询,模糊、通配符和正则匹配,大小写与重音折叠,以及 COUNT(*) 和 BM25 评分的 top-k 查询。在 Postgres 中,好的文本索引还必须处理连接、复杂 WHERE 子句、持续更新、复制、备份和事务可见性。
正文
客户问得最多的 Postgres 功能之一就是全文搜索。今天我们发布 TIN:一个快速、功能完整、可靠的 Postgres 全文搜索扩展。TIN 代表 "Text INdex",它做的就是这件事。
TIN 作为 GA 版本即刻可用,适用于所有 Postgres 和 Neki 数据库。可以这样用:CREATE INDEX an_index_name ON table_name USING tin(text_column_name); 然后 SELECT * FROM table_name WHERE text_column_name ==> 'some words';
我们构建 TIN,是因为我们相信好的文本索引应该支持:
- 布尔表达式、短语查询和 span 查询
- 支持词项的模糊、通配符和正则表达式匹配
- 大小写和重音折叠
COUNT(*)查询和 BM25 打分的 top-k 查询
而在 Postgres 里,好的文本索引还必须支持这些的同时处理 join、跨全文列和其他列类型的复杂 WHERE 子句、持续更新、复制、备份,以及正确的事务可见性。
Postgres 已经至少有三个文本搜索索引,但没有一个满足全部要求。TIN 满足。而且 TIN 快得惊人。
TIN 用来做什么
应用开发者用文本索引构建各种搜索功能。电商平台可能要搜索包含所有关键词的前十个商品:
SELECT * FROM products WHERE description ==> 'stretch denim jeans' ORDER BY tin.score(ctid) DESC LIMIT 10
法律取证平台可能必须返回包含一组关键词中任意一个的所有文档,但完全不在乎排序:
SELECT * FROM emails WHERE body ==> '[insider trading conspiracy]'
照片标签平台可能要显示带有某个标签的照片的精确数量:
SELECT COUNT(*) FROM photos WHERE tags ==> '"san francisco"';
大多数应用还需要插入、更新、删除文档,同时继续查询索引。搜索查询必须在新行或变更行提交后立刻返回匹配。
TIN 的性能和基准测试
我们跑了基准来评估上述及更多用例的性能。测试的工作负载包括:
- 合取(必须包含所有词)、析取(必须包含任意一个词)和短语(必须按顺序包含所有词)查询,以及三者的混合。
- 计数文档,或按 BM25 分数取 top k。
- 有和没有客户端在基准查询负载之外并发向索引写入新数据。
工作负载与语料
我们在多种文本语料上测量了 TIN:全部维基百科、总计 2.3 TB 的 Reddit 评论集合,以及一个我们简称为 "pile" 的混合负载,包含 797 GB 的开放获取研究论文、法律文书、公共领域书籍和 Enron 邮件。我们在这里分享的基准结果来自 Stack Exchange 问答导出:85 GB 语料,1.5 亿文档。由于该语料没有标准查询轨迹,我们通过采样 2 到 15 个词项的子串生成了合成查询,每个子串按合取、析取和短语三种方式解释,共 1,719 条查询。
测试环境
基准跑在一台 AWS i7i.8xlarge EC2 实例上,本地 NVMe 存储,现代支持 AVX-512 的 CPU。每个文本搜索扩展都跑在隔离容器中的 Postgres 18.6 里,限制 8 vCPU 和 32 GB 内存。这个配置小到足以展示索引不完全装进 Postgres 缓冲区时各系统的表现。基准阶段顺序运行,引擎之间不争抢资源。选择独立 EC2 实例是为了尽量减少运维开销和复制的影响,也确保想复现这些竞品基准的人能用同样的实例类型和容器限制来做。
搜索流量用 ParadeDB Benchmarker 打到 Postgres 容器。我们有一个 fork 版本,在开始测量前预热,并增加了读取字节数和 WAL 写入字节数的指标。除三个参数外,所有 Postgres 参数保持 Benchmarker 提供的默认值:max_parallel_workers 设为 8(原为 40),shared_buffers 设为 24 GB(原为 128 MB),maintenance_work_mem 设为 24 GB(原为 64 MB),以最贴合容器资源。Benchmarker 和目标 Postgres 服务器跑在同一台 EC2 上,确保网络延迟不影响测量。
每个场景中,我们测量 TIN v1.0.2 与所有至少能跑该负载的其他 Postgres 文本搜索索引:ParadeDB v0.25.2、pg_textsearch v1.4.0,以及 Postgres v18.6 内置的 GIN 索引。除 TIN 外,只有 ParadeDB 完成了全部基准。
索引构建时间和大小
索引大小在语料的 33% 到 61% 之间,准备、构建和收尾耗时 8 到 129 分钟。除 TIN 外的三个引擎在容器配置的 32 GB 限制下失败,因此仅在索引构建时按下表增加可用内存。查询前,容器对所有引擎调回 32 GB。
| | 总时间 | 索引大小 | 所需内存 | | --- | --- | --- | --- | | TIN | 8 分 10 秒 | 50.7 GB | 32 GB | | ParadeDB | 19 分 20 秒 | 52.1 GB | 64 GB | | pg_textsearch | 26 分 49 秒 | 41.5 GB | 128 GB | | Postgres GIN | 2 小时 09 分 04 秒 | 28.0 GB | 64 GB |
混合查询,top-10 排序
第一个基准比较 TIN 和 ParadeDB,工作负载为混合(合取、析取、短语)查询,按 BM25 分数取 top-10,无并发写入。TIN 每秒处理的查询量是 ParadeDB 的 25 倍,p99 延迟低 26 倍。GIN 无法完成,执行析取搜索时内存耗尽。pg_textsearch 无法完成,因为它只处理析取搜索。
合取和短语查询,top-10 排序
下一个基准比较 TIN 与 ParadeDB 和 Postgres GIN,top-10 合取和短语查询,无并发写入。TIN 和 ParadeDB 用 BM25 排序,GIN 用 ts_rank_cd。TIN 处理的查询量是 ParadeDB 的 10 倍、GIN 的 541 倍,p99 延迟分别低 6 倍和 1,356 倍。pg_textsearch 再次缺席,因为它只处理析取查询。
带并发写入的析取查询
第三个结果比较 TIN 与 ParadeDB 和 pgtextsearch,工作负载为析取查询,按 BM25 取 top-10,另有一个并发客户端目标为每秒 1,000 条 UPDATE。TIN 处理的查询量是 pgtextsearch 的 36 倍、ParadeDB 的 57 倍,p99 延迟分别低 24 倍和 36 倍。十分钟运行中,TIN 完成 270,279 次更新,ParadeDB 完成 185,584 次,pg_textsearch 仅完成 735 次。
ParadeDB 接受写入的方式牺牲了读吞吐和延迟。pg_textsearch 无论有无写入,读者的 QPS 都保持 3.5,因为持续的读流量让写流量拿不到所需的锁,写入几秒后就停滞。GIN 再次缺席,析取查询时内存耗尽。
当索引装进内存时
开头我们声称 TIN 快得惊人。
最后一张图展示 TIN、ParadeDB 和 Postgres GIN 在索引完全装进 shared buffers 时的表现。该工作负载对维基百科(8.0 GB 语料)做析取查询,计数(不排序)匹配文档。pg_textsearch 缺席,因为它只能做 top-k 查询,不能做计数查询。
完整结果
图表大概够了,但没覆盖我们全部用例。下面是这些场景以及另外几个场景的表格形式。"MB/query" 列显示每个索引每查询从磁盘或块缓存读取多少数据。TIN 的 MB/query 更低,这是它更快的原因之一,也减少了 TIN 查询对块缓存和 I/O 容量的影响,意味着同一服务器上的其他查询也能保持快。
合取、析取和短语查询;top-10:
| | QPS | p99 | MB/query | 更新数 | | --- | --- | --- | --- | --- | | TIN - 只读 | 199 | 256ms | 65 | | | TIN - 有更新 | 172 | 284ms | 88 | 271,398 | | ParadeDB - 只读 | 7.9 | 6,765ms | 582 | | | ParadeDB - 有更新 | 6.0 | 7,990ms | 591 | 193,487 |
合取和短语查询;top-10(只读):
| | QPS | p99 | MB/query | | --- | --- | --- | --- | | TIN | 242 | 212ms | 73 | | ParadeDB | 24 | 1,279ms | 668 | | Postgres GIN | 0.4 | 288,066ms | 595 |
析取查询;top-10:
| | QPS | p99 | MB/query | 更新数 | | --- | --- | --- | --- | --- | | TIN - 只读 | 148 | 324ms | 48 | | | TIN - 有更新 | 125 | 354ms | 77 | 270,279 | | ParadeDB - 只读 | 17 | 2,385ms | 303 | | | ParadeDB - 有更新 | 2.2 | 12,634ms | 394 | 185,584 | | pgtextsearch - 只读 | 3.5 | 8,646ms | 11,639 | | | pgtextsearch - 有更新 | 3.5 | 8,409ms | 11,656 | 735 |
合取、析取和短语查询;COUNT(*)(只读):
| | QPS | p99 | MB/query | | --- | --- | --- | --- | | TIN | 179 | 438ms | 97 | | ParadeDB | 10 | 2,704ms | 544 |
析取查询;COUNT(*);维基百科语料(只读):
| | QPS | p99 | MB/query | | --- | --- | --- | --- | | TIN | 10,260 | 2ms | 1.7 | | ParadeDB | 291 | 95ms | 22 | | Postgres GIN | 1.4 | 30,292ms | 2.5 |
可以看到,在多种场景中,TIN 的吞吐量至少比其他方案高 8 倍,从磁盘读取的数据少得多,即使索引每秒更新数百行,性能也只小幅下降。
TIN 为什么快
TIN 在基准中的表现可能让人难以置信。为了让这更可信,或者至少满足读者的好奇,我们解释一下让 TIN 如此快的架构选择。简而言之:所有文档 postings 都是 Postgres 的 ctid,而不是连续的文档标识符,这让它适合在现代 CPU 上做高度向量化的交集和并集操作。
文档标识
文本索引需要为它索引的每个文档的每个版本提供一个标识符,并把这些标识符分组为高度压缩的 postings list;每个 postings list 记录包含某个给定词的所有文档。在大语料中,像 "the" 这样的常见词的 postings list 可能有数十亿条,而像 "xyz-9876" 这样的词项的 postings list 只有几条。
大多数文本搜索系统把索引组织成段。某段中 n 个文档的 postings 通常分配 1 到 n 的文档标识符。连续的文档标识符让 postings list 能通过 delta 编码和位打包等技术高度压缩。但这也意味着不同段的文档标识符是独立分配的;段 4 里的文档 ID 42 和段 7 里的 ID 42 是完全不同的文档。
TIN 也把索引组织成段,但不是为了文档编号。TIN 直接使用 Postgres 的 ctid 值作为文档标识符。
Postgres 表中存储的每一行(tuple)的每个版本都有一个关联的 ctid。ctid 是 "current tuple identifier" 的缩写。任何插入或更新的行都会得到新的 ctid。它是一个 48 位数字,直接标识 tuple 在 Postgres 堆中的物理位置。文本表示为 (<块号>, <偏移号>),高 32 位是块号,低 16 位是块内偏移。后面我们把 <块号> 称为 "页号" 或 "页"。
给定 ctid (190, 17),我们就知道它代表的 tuple 在第 190 页的第 17 个槽位。O(1) 即时查找!甚至可以用 ctid 直接从堆中查询并取回行:
SELECT ctid, id, title FROM books ORDER BY ctid LIMIT 10; 按物理堆顺序取回 "books" 的前 10 行。
SELECT * FROM books WHERE ctid = '(190, 17)'; 不需要扫描,即时 O(1) 查找该行。
TIN 直接使用 ctid,因为 Postgres 内部就使用 ctid。实现新索引类型的 Postgres 扩展必须返回 ctid。Postgres 的 bitmap scan 背后是可能有损的 ctid 位图。Postgres 内部索引类型(b-tree、GIN、GiST 和 hash)都用 ctid 作为 postings。ctid 在 Postgres 里无处不在。
要在 Postgres 内运作,分配连续标识符的文本搜索系统必须在某个时刻把这些标识符转换回 ctid,Postgres 才能处理。ParadeDB 和 pgtextsearch 都维护一个单独的数据结构来做这个映射。如果文本搜索匹配 1,000 万行,ParadeDB 和 pgtextsearch 就要在 ctid 映射中查找 1,000 万个标识符。TIN 完全避免了这项工作。
48 位标识符很疯狂
常规的 postings list 压缩技术对不连续的 48 位数字效果不好。delta 编码在每个页边界失效,位图又太稀疏,效率低。所幸 Postgres 页的一些有趣特性让两级位图编码变得可行。一个 8KB 页最多只能容纳 291 个 tuple(8192 字节减去 24 字节页头,再除以每个非空 tuple 至少 28 字节),而对于含 TEXT 等列的表,页通常只含 32 个或更少的 tuple。所以页号列表足够密集,可以用位图;而每页内,偏移号列表也足够密集(且足够小),可以用每页的小位图。
相比直接存 48 位 ctid 值,节省相当显著。在整个语料上,高频词接近每 posting 1 比特,中频词约 7 比特,低频词约 25 比特。只出现一次的词根本不存为位图。
工作省略与向量化
TIN 的页级位图(哪些页含某个词)有 256 位,正好放进任何支持 AVX2 或更高的 x86 CPU 的向量寄存器。这带来了几项优化。
看查询 the AND rareword。TIN 一次 256 位(页)地对页级位图做 AND。交集中缺失的任何位,对应的页的偏移级位图 TIN 根本不需要解码。
对于 the OR rareword 这样的 COUNT(*) 析取查询,TIN 常常完全跳过读取 postings list。TIN 的索引元数据存有每个词的确切 posting 数。如果两个词的页级位图没有任何共同位,它们析取的计数就是这两个精确计数之和。
每个页级位图放进一个 AVX2 寄存器,每个偏移级位图放进一个 AVX-512 寄存器或两个 AVX2 寄存器。合取和析取查询就是这些向量寄存器上的 AND 和 OR 指令。计数匹配数的查询可以用 CPU 原生的 POPCNT 指令数结果位图中的位。昂贵的循环和分支指令基本可以避免。
要行而不是计数的查询从位位置计算 ctid,而不是从磁盘查。置位的位置就是 ctid。
TIN 从某段返回给 Postgres 的文档 ctid 天然按堆顺序标识页和页内 tuple。这意味着 Postgres 需要从堆读取匹配 tuple 时,是按堆顺序进行的。即使现代 NVMe 磁盘,顺序访问也远快于随机访问;TIN 免费获得这个优化。
解决 MVCC
TIN 返回的结果是 MVCC 正确的,意味着任何时刻执行的语句只看到或只操作当前对它可见的 tuple。这意味着每个基于堆的查询结果都需要对照当前快照检查可见性。
堆检查
有几种不同做法。有些查询天然要做堆检查:
SELECT a, b, c FROM lyrics WHERE content ==> 'give you up'
因为查询返回实际堆数据(a, b, c 列),TIN 无论如何都必须从堆取回 ==> 'give you up' 返回的所有匹配 ctid。当 TIN 向 Postgres 要每个 ctid 背后的物理 tuple 数据时,Postgres 会告诉 TIN 该 tuple 对当前快照是否可见。可见就返回,否则移到下一个匹配 ctid,直到返回所有可见匹配。
可见性映射
其他查询形状可以像 Postgres 的 "Index Only Scan" 那样执行,答案直接从索引返回而不碰堆(或至少希望不碰整个堆)。考虑这样的纯计数查询:
SELECT COUNT(*) FROM lyrics WHERE content ==> 'give you up'
如果每个堆页都标记为 all-visible,TIN 可以不碰任何一个堆页就返回计数。
当然并非所有数据都是静态的。堆发生变更时,TIN 做额外优化,直接与 Postgres 的 visibility map 求交,确保只计可见行。TIN 的页级位图正是与 Postgres visibility map(也是页级位图)高效求交的合适机制。只有非 all-visible 页上的 ctid 才需要对照堆检查。通常 Postgres 索引返回所有匹配的 ctid 而不看可见性,由 Postgres 执行器逐个检查。TIN 规划自定义扫描,把可见性检查移进 TIN 内部,从而能利用页级位图上的向量指令。
VACUUM 与 TIN 的存活位图
支持删除文档的文本索引通常保留某种适合其引擎的 "tombstone" 列表。TIN 也不例外。TIN 为每段维护一个存活位图,每个 ctid 一位,组织方式与页级和偏移级位图相同。VACUUM 运行并判定某个 ctid 已被从堆中删除(UPDATE 或 DELETE 的结果)时,TIN 清除该 ctid 的存活位。至少有一个清除位的页组会被标记,查询触及被标记的页组时,TIN 还会把 postings list 中的偏移位图与存活位图做 AND,因此永远不会返回或计数真正被删除的 tuple。
段与合并
TIN 首次为表创建新索引时,会创建 n 个不可变段,每段包含该表堆中 1/n 页的 postings。数据变更时,TIN 创建可变段,搜索效率较低,但便于插入新文档。最终,后台 worker 把每个可变段提升为不可变段:不再变化,但搜索效率高得多。
一段时间后,TIN 开始把不可变段合并为更大的不可变段。这也在后台进行。
使用连续文档标识符的文本索引系统,在创建合并后的新段时必须重新编号所有文档。如前所述,段 4 里的文档 ID 42 和段 7 里的 ID 42 不是同一个。所以合并段 4 和 7 时,必须对合并后的文档集重新编号,每段数据整体重新打包、重新压缩、重写。合并两段的存储虽然不是正好 2 倍,但也接近。
TIN 没有重新编号问题,也没有随之而来的写放大效应。
因为 TIN 用 Postgres 的 ctid 值作为文档标识符,没有什么需要重新编号。像 (190, 17) 这样的 posting 在每个段中含义相同。页级和偏移级位图在每个段中含义相同。TIN 合并段时,每个旧段的许多位图可以在新段中原样重用。它们不必被重新压缩,甚至不必被复制;TIN 可以直接把存储在磁盘上的位图的所有权从旧段转给新段。这减少了写放大,省下了通常与合并段相关的大部分 CPU 和 I/O 成本。
总结
这就是为什么 TIN 在每个基准中都至少快 8 倍:选择 ctid 作为索引中每个 posting 的原生格式所带来的下游效应。
如果你想知道 TIN 在你的文本数据上有多快,可以进一步了解功能,或直接看入门指南。我们期待看到你用 TIN 构建的东西。
评论
讨论对 PlanetScale 给 Postgres 加全文检索这件事整体持怀疑态度:分歧主轴在于「Postgres 缺不缺这个能力」——一方认为内置的 tsvector/tsquery 已经够用甚至被低估,另一方认为现有方案确实难用、痛点真实;质疑则集中在它是否闭源云锁定,以及是否只是把 BM25 重新包装一遍。
认为 Postgres 内置全文检索已经够用,没必要引入第三方扩展
- 有开发者直接反问:既然 tsvector/tsquery/tsrank 与函数索引、查询优化器都做了集成,为什么要换成非核心、看起来像 AI 随手写出来的东西(较多人)
- 也有人提醒先读 Postgres 手册,内置检索能力相当强;另有人追问它相对 tsvector 配 GIN/GiST 索引到底强在哪(较多人) > 既然核心 Postgres 已经有这套东西,我为什么要用一个不在核心里的「氛围编程」产物?(@tannhaeuser)
认为现有方案确实难用,痛点不是伪需求
- 有开发者表示 tsquery 体验谈不上好,只比 LIKE 强一点,代价却是索引体积暴增,因此希望这个扩展能开源、在真实场景里验证出甜点区间(较多人)
- 有人对「开源标杆 Postgres 至今没有像样的全文检索」感到意外,并称自己在 MySQL 和 SQLite 的 FTS 上都用得挺顺(少数人)
- 侧面证据:SQLite 的 FTS 开箱即支持 Lucene 式查询且性能很好,只是写入久了会变慢(少数人)
怀疑是云锁定,重演「拥抱、扩展、消灭」
- 有人指出贴出的资料里看不到 GitHub 链接,怀疑走的是先兼容再锁定的老路(较多人)
- 关键事实补充:他们并没有提供同等性能的本地扩展,本地版本主要用来验证语法,性能特征和云端不一致,同等性能只在云服务上提供(较多人) > 看不到任何 GitHub 链接,这是不是 21 世纪的「拥抱、扩展、消灭」?(@znpy)
认为这件事真正的意义是 BM25 正在被商品化
- 有观点把这轮现象归因于 AI 编码生产力落地:从 paradeDB 的 pgsearch、Timescale 的 pgtextsearch,到 Neon/Databricks 的 Lakebase Search,再到 PlanetScale,几乎都是 BM25 的不同实现;照着资料让 agent 在你选的系统里实现一遍就行,架构与集成方式仍有优化空间,但要警惕功能被彻底同质化(较多人)
认为 SQL 内建全文检索与关系模型天然阻抗失配
- 有开发者表示在 SQLite 和 MSSQL 里用 FTS 一直很别扭,文档存储方式和关系型关切难以对齐,因此更倾向于把 SQL 当记录系统、另建维护一个外部 Lucene 索引;他想知道现在是否已经过了混合架构还有意义的临界点,以及这个方案的可定制程度有多高(少数人)
认为这类能力 MongoDB 多年前就有了
- 有人表示每次看到这类更新都会想,MongoDB 早就具备这些,并且对自己生产栈跑在 Mongo 上感到满意(少数人)
迁移意愿受生态锁定压制
- 有人想试 PlanetScale,但团队已经完全依赖 Neon 的分支模型,被这套工作流「钩住」了(少数人)
有价值的补充
- 注意命名冲突:PostGIS 里 TIN 是三角不规则网数据类型,和这个搜索扩展同名。
- SQLite 的 FTS 可以直接吃 Lucene 风格查询且性能不错,只有长期写入会明显变慢——这被用来反衬 Postgres 生态长期缺一个同等水准的方案。
- 各家方案虽然都叫全文检索,但实现路径、索引结构和集成深度差别很大,架构层面还有不少可挤压的空间,短期内不宜当作完全同质的商品。
- 玩笑一条,代表社区对产品名的即时反应:「它对的起 TIN 这个名字。」(@dorianmariecom)