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

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


相关阅读(延伸外链)

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

相关推荐
全栈项目管理程序猿2 小时前
ArcGIS JS 基础教程(28):图层渲染顺序管理
前端·javascript
huakoh2 小时前
MCP 工具报错走哪条通道:三条探针的最小复现检查
前端
不爱说话郭德纲2 小时前
从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本
android·前端·uni-app
Lstone73642 小时前
从 Jetpack Compose 到 CMP:跨平台开发学习笔记
前端
deli0072 小时前
多加一粒沙,整堆为什么就塌了?sandpile 模型 20 万粒实测
前端
涛涛ing2 小时前
乱序HTML流正式进入浏览器:前端流式渲染的“框架特权”被终结了
前端
__sjfzllv___2 小时前
在职前端Leader学习/转行 AI Agent -DAY73
前端
用户1733598075372 小时前
纯前端 PDF 压平避坑指南:压平后表单字段变了?
前端·javascript·vue.js
nyaomaru2 小时前
将一个真实的 TypeScript OSS 库从 tsup 迁移到 tsdown
前端·typescript