ParadeDB 的 pg_search 0.26 把十词 BM25 搜索从 129ms 降到 29ms

0 阅读10分钟

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 的查询:

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.11130.3ms127.5ms129.2ms
0.26.031.5ms29.5ms29.4ms

这是 4.3 倍的下降,而且在重复运行中保持一致,不是一次性的。EXPLAIN (ANALYZE, BUFFERS) 说明了原因。在 0.25.11 上,计划在自定义扫描节点下列出了 Queries: 3——pg_search 在运行三个独立的计分子查询并把它们合并。在 0.26.0 上,同样的 SQL 产生 Queries: 1,外加一个旧计划中不存在的缓冲区明细:

Buffer Hits:
 Total: 645
 Columnar Fields: 29
 Field Norms: 190
 Heap: 7
 Postings: 407
 Term Dictionary: 12

把三个内部查询折叠成一个,同时 fieldnorms 现在紧挨着 postings,而不是放在规划器必须访问三次的独立结构中——4.3 倍就来自这里,而不是缓存预热差异或我这特定表的侥幸。

反对它的那个数字

在跑任何其他东西之前,我的工作假设是:一个号称"BM25 更快"的版本不可能让查询变慢,最坏也就是打个平手。一个单词项查询,没有析取,同样的 LIMIT 10,立刻打破了那个假设:

SELECT id, title, paradedb.score(id) AS score
FROM posts WHERE title @@@ 'kernel'
ORDER BY score DESC LIMIT 10;
版本运行 1运行 2运行 3
0.25.111.56ms1.34ms1.36ms
0.26.08.06ms7.70ms8.55ms

在一个出现在 19% 行中的词项上,这大约慢了五倍,而且持续稳定。一个更稀有的词("udp",约 10% 的行)在更小的绝对数值上表现出同样的模式:0.25.11 上是 1.5–1.6ms,0.26.0 上是 4.8–5.7ms。

我的第一反应是我把实验装置弄坏了——也许第二个容器的缓存是冷的,也许我打错了查询。我在每个版本上连续重跑了五次,每次都得到同样的差距,然后才拉取 EXPLAIN (ANALYZE, BUFFERS) 检查,然后再下结论:

-- 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 次中最快)
11.34ms7.70ms
28.10ms16.07ms
420.40ms14.83ms
10127.5ms29.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,听起来应该会让索引膨胀。事实并非如此,至少在目前这个规模上:

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 如何处理我不确定是否真实的配置:

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 名称都会被干净地捕获:

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 tps0.26.0 tps0.25.11 平均延迟0.26.0 平均延迟
10 词项 OR14.351.6698.5ms193.8ms
单词项2658.8477.33.76ms20.95ms

并发不会改变任何一个效应的方向,它会放大两者。析取收益站得住(吞吐量 3.6 倍),而单词项回退在争用下比单连接时看起来更糟(吞吐量 5.6 倍)。

我一路上的错误

我第一次写数据生成器时用朴素的 random.paretovariate 来选择单词,而且没仔细看产出的内容,直到它已经把 300 万行写进磁盘。很多标题读起来像 "Account alert and account account account"——一个词在同一行里挤掉了其他所有词。这对 BM25 尤其要紧,因为分数依赖一个词项在单个文档内出现的频率,而同一个词出现五次的标题,真实的搜索框永远不会发给我。我把文件扔掉,改了生成器让每个标题每个词只出现一次,肉眼抽查了样本,然后才重新加载两个数据库并开始计时。

自己动手跑

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

生成一个可比的数据集并加载到两者中:

# 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")
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 比较:

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 这个数字。


相关阅读(延伸外链)

以下为推荐的相关技术教程,来自致知笔记: