ParadeDB 于 10 月 3 日发布了 pg_search 0.26.0,更新日志将其描述为对扩展存储 BM25 字段长度数据方式的重写。公司自己的说法是搜索变快了。我想知道到底哪里变快了、哪里变慢了,因为没有哪次重写是没有代价的。
pg_search 是一个 Postgres 扩展,在普通的 btree 世界之外增加了一个 BM25 全文索引(USING bm25),所以你可以在不离开 Postgres 的情况下运行相关性排序的搜索。0.26 版本把"fieldnorms"------BM25 评分所需的每文档字段长度数字------从一个共享数组移入每个词项的 posting list 中。我在相同的数据和相同的查询上分别运行了上一个稳定版(0.25.11)和 0.26.0,看看这次迁移实际付出了什么、换来了什么。
实验设置
我拉取了 paradedb/paradedb:0.25.11-pg17 和 paradedb/paradedb:0.26.0-pg17,每个版本跑一个容器,并向每个容器加载一张完全相同的 300 万行表:一个合成的 title 列,由约 260 个词的词表按 Zipfian 频率分布构建,所以有些词(如 "index")出现在 84% 的行中,而另一些词(如 "udp")只出现在约 10% 的行中。我在两者上都用默认选项在 title 上构建了默认的 bm25 索引,用 psql 里的 \timing on 计时,并用 EXPLAIN (ANALYZE, BUFFERS) 支撑每一个数字。
头条数字
一个把十个词用 OR 组合起来、并按 BM25 分数取前 10 的查询,正是 ParadeDB 自己的基准测试所针对的形状------一个对多个词项做析取、排序并带 LIMIT 的查询:
bash
SELECT id, title, paradedb.score(id) AS score
FROM posts
WHERE title @@@ 'index OR which OR to OR price OR an OR ssl OR transaction OR tcp OR api OR kernel'
ORDER BY score DESC
LIMIT 10;
| 版本 | 运行 1 | 运行 2 | 运行 3 |
|---|---|---|---|
| 0.25.11 | 130.3ms | 127.5ms | 129.2ms |
| 0.26.0 | 31.5ms | 29.5ms | 29.4ms |
这是 4.3 倍的下降,而且在重复运行中保持一致,不是一次性的。EXPLAIN (ANALYZE, BUFFERS) 说明了原因。在 0.25.11 上,计划在自定义扫描节点下列出了 Queries: 3------pg_search 在运行三个独立的计分子查询并把它们合并。在 0.26.0 上,同样的 SQL 产生 Queries: 1,外加一个旧计划中不存在的缓冲区明细:
bash
Buffer Hits:
Total: 645
Columnar Fields: 29
Field Norms: 190
Heap: 7
Postings: 407
Term Dictionary: 12
把三个内部查询折叠成一个,同时 fieldnorms 现在紧挨着 postings,而不是放在规划器必须访问三次的独立结构中------4.3 倍就来自这里,而不是缓存预热差异或我这特定表的侥幸。
反对它的那个数字
在跑任何其他东西之前,我的工作假设是:一个号称"BM25 更快"的版本不可能让查询变慢,最坏也就是打个平手。一个单词项查询,没有析取,同样的 LIMIT 10,立刻打破了那个假设:
bash
SELECT id, title, paradedb.score(id) AS score
FROM posts WHERE title @@@ 'kernel'
ORDER BY score DESC LIMIT 10;
| 版本 | 运行 1 | 运行 2 | 运行 3 |
|---|---|---|---|
| 0.25.11 | 1.56ms | 1.34ms | 1.36ms |
| 0.26.0 | 8.06ms | 7.70ms | 8.55ms |
在一个出现在 19% 行中的词项上,这大约慢了五倍,而且持续稳定。一个更稀有的词("udp",约 10% 的行)在更小的绝对数值上表现出同样的模式:0.25.11 上是 1.5--1.6ms,0.26.0 上是 4.8--5.7ms。
我的第一反应是我把实验装置弄坏了------也许第二个容器的缓存是冷的,也许我打错了查询。我在每个版本上连续重跑了五次,每次都得到同样的差距,然后才拉取 EXPLAIN (ANALYZE, BUFFERS) 检查,然后再下结论:
bash
-- 0.25.11,单词项 'kernel'
Buffers: shared hit=132
-- 0.26.0,单词项 'kernel'
Buffer Hits:
Total: 496
Columnar Fields: 6
Field Norms: 382
Heap: 9
Metadata: 16
Postings: 71
Term Dictionary: 12
496 次缓冲区命中对 132 次,其中 382 次专门是 fieldnorms。把 fieldnorms 移到 postings 旁边,意味着每个词项的 posting list 现在都携带自己的字段长度数据副本,而不是所有词项共享一个紧凑的数组。对单个词项来说这是纯粹的额外开销。对组合成一次计分遍历的十个词项来说,这正是避免上面三查询分裂所需的局部性。同一个设计决策解释了这两个数字。
交叉点在哪里
我试了 2 个和 4 个词项,看看权衡在哪里翻转:
| 词项数(OR) | 0.25.11(3 次中最快) | 0.26.0(3 次中最快) |
|---|---|---|
| 1 | 1.34ms | 7.70ms |
| 2 | 8.10ms | 16.07ms |
| 4 | 20.40ms | 14.83ms |
| 10 | 127.5ms | 29.4ms |
在 2 到 4 个 OR 词项之间的某个点,0.26.0 不再是较慢的选项并开始反超,之后差距迅速拉大。一个只有一两个搜索词的查询从这个版本得不到任何好处,反而要付真实代价;一个有四个或更多词的查询则获得不断增长的收益。
我还检查了一个 4 词项 AND 查询(合取,不是析取),因为这个优化专门针对析取剪枝:0.25.11 上是 8.7--10.7ms,0.26.0 上是 9.5--11.4ms,在我三次运行的样本噪音内是一个小回退。AND 查询不是这个版本的目标,数字也清楚地表明了这一点。
磁盘上的代价
把 fieldnorms 复制进每个词项的 postings,听起来应该会让索引膨胀。事实并非如此,至少在目前这个规模上:
bash
SELECT pg_total_relation_size('search_idx');
-- 0.25.11: 100,917,248 bytes
-- 0.26.0: 101,138,432 bytes
在一个 96MB 的索引上多了 216 KiB,大约 0.2%。无论新的磁盘布局做了什么,它都不是在按词项天真地复制一份 fieldnorm 数组。
它拒绝的东西
我试图找到控制新字段长度存储的确切配置标志,因为项目自己的 PR 说明提到了重建索引和一个可选的字段选项来获得完整的评分收益。我没能在能访问的文档页面中找到这个标志的名字,所以我测试了 pg_search 如何处理我不确定是否真实的配置:
bash
CREATE INDEX idx ON posts_small USING bm25 (id, title)
WITH (text_fields='{"title": {"pnorms": true}}');
-- CREATE INDEX(无错误,无可见效果)
CREATE INDEX idx ON posts_small USING bm25 (id, title)
WITH (text_fields='{"title": {"not_a_real_option_xyz": true}}');
-- CREATE INDEX(也无错误)
两者都静默成功了。这并不是严格性方面的普遍漏洞------格式错误的 JSON 和无效的 tokenizer 名称都会被干净地捕获:
bash
ERROR: failed to deserialize field config: Error("key must be a string", line: 1, column: 2)
ERROR: field config should be valid for SearchFieldConfig::title: unknown tokenizer type: not_a_real_tokenizer
所以这个扩展会校验已知的、强类型字段,如 tokenizer,但 text_fields 内部一个无法识别的键会被无提示地丢弃。如果新行为的真实选项名和你猜的拼写不同,或者和某个旧文档页面说的不同,你不会从错误消息里发现。我还发现一个表同时只能有一个 pg_search 索引(ERROR: a relation may only have one ParadeDB index),而且长期存在的 key_field 选项现在是一个有文档说明的 no-op:WARNING: key_field is deprecated as of 0.26.0 and is a no-op。
我会在生产中怎么观察它
EXPLAIN (ANALYZE, BUFFERS) 中的缓冲区明细是 0.26 新增的,是我发现的、在真实系统上诊断这个权衡时最直接有用的东西。在你自己慢查询上跑它,会告诉你是在为一个窄查询支付 fieldnorm 税,还是在一个宽查询上收获析取折扣,而不需要仅凭计时来猜测。
负载之下
我通过 pgbench 在每个版本上以 10 个并发客户端运行了两种查询形状各 10 秒:
| 查询 | 0.25.11 tps | 0.26.0 tps | 0.25.11 平均延迟 | 0.26.0 平均延迟 |
|---|---|---|---|---|
| 10 词项 OR | 14.3 | 51.6 | 698.5ms | 193.8ms |
| 单词项 | 2658.8 | 477.3 | 3.76ms | 20.95ms |
并发不会改变任何一个效应的方向,它会放大两者。析取收益站得住(吞吐量 3.6 倍),而单词项回退在争用下比单连接时看起来更糟(吞吐量 5.6 倍)。
我一路上的错误
我第一次写数据生成器时用朴素的 random.paretovariate 来选择单词,而且没仔细看产出的内容,直到它已经把 300 万行写进磁盘。很多标题读起来像 "Account alert and account account account"------一个词在同一行里挤掉了其他所有词。这对 BM25 尤其要紧,因为分数依赖一个词项在单个文档内出现的频率,而同一个词出现五次的标题,真实的搜索框永远不会发给我。我把文件扔掉,改了生成器让每个标题每个词只出现一次,肉眼抽查了样本,然后才重新加载两个数据库并开始计时。
自己动手跑
bash
docker run -d --name pdb_old -e POSTGRES_PASSWORD=pw -p 55432:5432 paradedb/paradedb:0.25.11-pg17
docker run -d --name pdb_new -e POSTGRES_PASSWORD=pw -p 55433:5432 paradedb/paradedb:0.26.0-pg17
sleep 8
生成一个可比的数据集并加载到两者中:
bash
# gen_data.py
import random
import numpy as np
random.seed(42); np.random.seed(42)
VOCAB = np.array(sorted(set("""the a an of to in and is for on with that this it
database postgres index query search performance api cache kernel transaction
tcp udp ssl shard worker storage container cluster token startup price""".split())))
random.shuffle(VOCAB)
N = len(VOCAB)
weights = 1.0 / (np.arange(1, N + 1) ** 1.05)
weights /= weights.sum()
with open("posts.csv", "w") as f:
for i in range(1, 500_001):
length = random.choice([4, 6, 8, 10])
words, seen = [], set()
while len(words) < length:
w = np.random.choice(VOCAB, p=weights)
if w not in seen:
seen.add(w); words.append(w)
title = " ".join(words)
f.write(f"{i},{title[0].upper() + title[1:]},{random.randint(1, 5000)}\n")
bash
python3 gen_data.py
docker cp posts.csv pdb_old:/tmp/posts.csv
docker cp posts.csv pdb_new:/tmp/posts.csv
for c in pdb_old pdb_new; do
docker exec $c psql -U postgres -c "CREATE TABLE posts (id bigint PRIMARY KEY, title text NOT NULL, points int);"
docker exec $c psql -U postgres -c "\COPY posts FROM '/tmp/posts.csv' WITH (FORMAT csv);"
docker exec $c psql -U postgres -c "CREATE EXTENSION IF NOT EXISTS pg_search;"
docker exec $c psql -U postgres -c "CREATE INDEX search_idx ON posts USING bm25 (id, title) WITH (key_field='id');"
done
# key_field 在 0.25.11 上是必需的,在 0.26.0 上是已弃用的 no-op,所以这在两个版本上都能工作
然后用 \timing on 比较:
bash
SELECT id, title, paradedb.score(id) AS score FROM posts
WHERE title @@@ 'index OR which OR to OR price OR an OR ssl OR transaction OR tcp OR api OR kernel'
ORDER BY score DESC LIMIT 10;
SELECT id, title, paradedb.score(id) AS score FROM posts
WHERE title @@@ 'kernel'
ORDER BY score DESC LIMIT 10;
对 55432 端口上的 pdb_old 和 55433 端口上的 pdb_new 各跑三次,比较 Time: 行。
重要的是你自己的查询属于哪个类别,而唯一的方法就是去检查。如果你的搜索框大多接收一两个词,据我测到的数据,这个升级对你最多只是打个平手------我数字里的交叉点大约在三个到四个 OR 词项之间。分面搜索、标签过滤器,任何把几个值 OR 在一起的东西,就是不同的情况了,而那也是我每次尝试都看到收益的地方。在两个版本上对你真正慢的查询跑 EXPLAIN (ANALYZE, BUFFERS),在决定你站在哪一边之前看看 Field Norms 这个数字。
相关阅读(延伸外链)
以下为推荐的相关技术教程,来自致知笔记: