跳到主要内容

ParadeDB 的混合检索与 RRF:文本和向量装在同一个索引里,但融合那一段要自己写 SQL

· 阅读需 21 分钟

Postgres 上做混合检索,过去的标准姿势是「pgvector 管向量 + tsvector 管关键词 + 一条 SQL 自己融合」——本站 pgvector 入门 里那条 WITH ... RRF 就是这个写法。

ParadeDB 想解决的是这套姿势里最别扭的部分:两套索引、两份候选、两个计划。它的答案是——把文本列和向量列放进同一个索引,让 BM25 和向量距离在同一趟扫描里完成。

但有个反直觉的事实,我这次实测才确认:

ParadeDB 不提供 rrf() 函数,也不提供 hybrid_search() 之类的 API。 它的「RRF」是一段要你自己写的 SQL 模式——RANK() 窗口函数排名次、weight / (k + rank) 算权重、sum() 融合。

我查了 pg_proc,pg_search 0.25.10 里不存在任何名字含 rrf / fuse 的函数。这和 Milvus 是两个路线:Milvus 内置 RRFRanker()(本站 Milvus 入门 验算过它的分数就是教科书公式),ParadeDB 则把融合层的控制权原样交回 SQL。

这篇把它的实现路径跑了一遍,所有数字都来自真实执行(脚本与日志留档在 source/paradedb/)。

命名说明:你说的「RFF 算法」,业界标准名是 RRF(Reciprocal Rank Fusion,互惠排名融合),本文统一用 RRF。

一、结论先行​

三条,后面都有证据:

  1. 没有内置融合函数。 RRF 由你自己用 CTE + RANK() 窗口 + sum(weight) 拼出来。
  2. 索引是共享的。 文本列和向量列在同一个 USING paradedb 索引里,所以两路能各自带着 LIMIT 下推进索引——EXPLAIN 里能看到两个 TopKScanExecState。
  3. 融合层不下推。 Append / HashAggregate / Sort 是普通 Postgres 算子。融合是 SQL 层的事,这和「两路各自下推」是两件事,官方文档也是分开讲的。

二、实验设置​

环境PostgreSQL 18.6、pg_search 0.25.10、vector(pgvector)0.8.4
镜像paradedb/paradedb:latest(976 MB),docker 容器 pdb-rrf
数据12 行玩具数据,id / description / category / embedding vector(4)
脚本source/paradedb/rrf_lab.sql(主实验)、rrf_lab2.sql(边界对照)、jieba.sql(中文场景)

测试数据是刻意设计的,为了让融合结果可以逐位手工验算:向量做成 v(i) = [cos θ, sin θ, 0, 0]、θ = (i-1)×10°,查询向量 q = [1,0,0,0]。此时余弦距离 1 - cosθ 随 id 单调递增,向量分支的排名恰好等于 id 顺序。

文本侧我不做控制——BM25 的名次是引擎给的真实结果。所以下面的验算表用的是实测出来的名次,不是设计出来的名次。这一点很重要:如果两边都是我自己排的,那就只是在验证我自己的算术。

三、物理底座:文本列和向量列在同一个索引里​

CREATE INDEX search_idx ON mock_items
USING paradedb (id, description, category, embedding vector_cosine_ops)
WITH (key_field='id');

两个要点。

第一,向量类型来自 pgvector,但索引不用它的。 官方逐字:

ParadeDB uses pgvector's vector types, but not its HNSW or IVF indexes.

也就是说 vector_cosine_ops 这个 opclass 名沿用了 pgvector 的命名,但底层不是 HNSW/IVFFlat,而是 ParadeDB 自己的索引(底层 Tantivy)。列是 pgvector 的,检索是 ParadeDB 的——这两件事别混。

第二,access method 的名字变过。 0.25.x 是 USING paradedb;旧版本文档里叫 USING bm25。照抄老博客的 DDL 会直接失败,这是跨版本迁移最容易踩的一脚。

然后是算子与 opclass 必须匹配——官方原文(reference/vector/querying):

This operator must match the distance metric used by the index. Otherwise, ParadeDB cannot use the index to order results and falls back to a slower brute force sort.

Operator距离索引设置
<->L2vector_l2_ops
<=>余弦vector_cosine_ops
<#>内积vector_ip_ops

这条和本站 pgvector 篇里那条「算子与 opclass 必须匹配,233ms vs 3.7ms」是同一个坑的两种表现——那边是规划器不选索引,这边是直接退化成暴力排序。同一个约束,写在哪家都一样。

四、RRF 的 SQL 形状​

官方的形状是「每路一个 ORDER BY ... LIMIT,配一个同名 RANK() 窗口函数,WITH 子句负责融合」。核心公式,官方逐字:

Each branch contributes weight / (k + rank), and the contributions are added.

WITH text AS (
SELECT id, RANK() OVER (ORDER BY pdb.score(id) DESC, id) AS rank
FROM mock_items
WHERE description ||| 'running shoes'
ORDER BY pdb.score(id) DESC, id
LIMIT 20
),
vector AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> '[1,0,0,0]', id) AS rank
FROM mock_items
WHERE id @@@ pdb.all()
ORDER BY embedding <=> '[1,0,0,0]', id
LIMIT 20
),
fused AS (
SELECT id, sum(weight) AS score
FROM (
SELECT id, 1.0 / (60 + rank) AS weight FROM text
UNION ALL
SELECT id, 0.7 / (60 + rank) AS weight FROM vector
) u
GROUP BY id
)
SELECT m.id, m.description, f.score
FROM fused f
JOIN mock_items m USING (id)
ORDER BY f.score DESC, m.id
LIMIT 5;

三个细节值得单独说。

① 两路的 WHERE 天生不一样。 description ||| 'running shoes' 是文本路的查询(||| 是 match disjunction,即「任一词命中」);向量路的查询条件藏在 ORDER BY 的距离里,所以它的 WHERE 只能写 pdb.all()(匹配所有行)。官方特意提醒:要让两路从同一批候选里选,过滤条件必须两边都加,否则一个在全集里排、一个在子集里排,名次根本不可比。

② 为什么按位次而不是按分数融合。 官方给的理由(逐字):

BM25 scores and vector distances operate on different scales, so they cannot be added together directly. … a row that ranks first contributes the same amount whether its BM25 score was 1 or 100.

这正是 RRF 的立身之本:丢掉分数、只留位次,就不需要归一化,也对尺度不敏感。注意别把它当精度方案推销——本站 多路检索那篇 实测过,RRF 的精度略低于 CombSUM/凸组合,它的优势在工程而不在精度。

③ 这里的 RANK() 不是必须用 RANK()。 用 ROW_NUMBER() 也能跑,区别只在并列怎么处理——这点在 §七 会看到它是个真实的坑。

五、实测:两路各自的 top-1 都不是融合后的 top-1​

12 行数据,k=60、w_text=1.0、w_vector=0.7:

分支top-1top-2top-3
① 文本(BM25)单跑id 12(3.2423)id 11(2.3881)id 5(2.0945)
② 向量单跑id 1(距离 0)id 2(0.0152)id 3(0.0603)
③ RRF 融合id 5(0.02664225)id 4id 12

三条数据各自的原因,都查得到:

  • id 12 文本第一,是因为它的描述里 running shoes 重复了三次(词频高),但它向量距离最差 → 位次第 12;
  • id 1 向量第一(语义最近),但描述里压根没有 "running shoes" → 文本路根本没召回它;
  • 融合的赢家是 id 5——文本第 3、向量第 5,两路都认可。

这就是 RRF 的核心机制:不是「谁的第一名更强」,而是「谁被两路同时高排」。两路同时进前 5 的文档,分数会叠加成接近两倍,压过任何一路的单独头名。

顺手把分数逐条验算了一遍,完全对上,误差 0:

idtext_rankvec_rank1.0/(60+rank)0.7/(60+rank)手算期望SQL 实际输出
5350.0158730.0107690.026642250.02664225
4440.0156250.0109380.026562500.02656250
121120.0163930.0097220.026115660.02611566
112110.0161290.0098590.025988190.02598819
8580.0153850.0102940.025678730.02567873

顺带注意 id 12 那行:它文本第 1(贡献 0.016393 是全场最大的单项),但向量第 12(0.009722 是全场最小),合计掉到第 3。这就是 RRF 惩罚「单路极端、另一路缺席」的方式。

六、EXPLAIN 证据:两路都下推了,融合没下推​

-> Hash Join
-> Seq Scan on mock_items m <- 只是回表取 description
-> Hash
-> HashAggregate <- 融合层:普通 SQL 算子,不下推
-> Append
-> Subquery Scan -> Limit -> WindowAgg
-> Custom Scan (ParadeDB Base Scan) on mock_items
Exec Method: TopKScanExecState
TopK Order By: pdb.score() desc, id asc
TopK Limit: 20
-> Subquery Scan -> Limit -> WindowAgg
-> Custom Scan (ParadeDB Base Scan) on mock_items
Exec Method: TopKScanExecState
TopK Order By: embedding <=> vector asc, id asc
TopK Limit: 20

两个 TopKScanExecState,各自带着 TopK Limit: 20。

这印证了官方那句关于分支 LIMIT 的话:

Because both branches push the limit into the index, raising it is much cheaper than it would be with a full sort.

换句话说:LIMIT 20 不是「先取全量再截断」,而是「只要 20 条,索引里直接按序吐 20 条」。 所以官方建议的「每路取 100~200 条候选来出 top-10」并不贵——这个建议的代价被下推吃掉了。

但融合层(Append / HashAggregate / Sort / 回表 Hash Join)是普通 Postgres 算子,和「两路各自下推」是两件独立的事。ParadeDB 优化的是「每一路怎么取」,不优化「两路怎么合」——合的成本是 O(候选数),在小候选集上可以忽略,但这解释了为什么融合逻辑写得越复杂,越是你自己的开销。

官方还给了验收标准,很实用:看不到 TopKScanExecState,那一路就没走优化路径。 我按这条抓出了 §七 的坑 2。

七、四个旋钮,逐个实测​

7.1 k:默认 60,但只有极小的 k 才改变结果​

官方说 "60 is the conventional value"。我把同一条 SQL 只改 k 跑了一遍:

k排序(前 5 之后的略)
112 > 11 > 5 > 1 > 4 > 8 > …
105 > 12 > 4 > 11 > 8 > …
605 > 4 > 12 > 11 > 8 > …
1005 > 4 > 12 > 11 > 8 > …
10005 > 4 > 12 > 11 > 8 > …

k=1 会实打实改变排序——纯文本头名 id 12 冲回第一,因为 k=1 时 rank 1 与 rank 2 的差是 1/2 - 1/3,差距被放得极大,头部彻底主导。而 k 从 10 到 1000 结果完全一致。

这和本站 Rerank 篇 用 Milvus 数据做出的「k 不影响顺序」是同一个结论,但这里更精确了一层:k 的敏感区在很小的取值(官方也这么说:lower k 20–40 让头部主导、higher k 80–100 让两路的「共识」更重要)。默认 60 附近非常稳,没事不要动它。

局限:这是本数据集上的观测,不能当成普适结论。

7.2 权重:改了会变,且必须用数据调​

只把 1.0 / 0.7 换成 0.7 / 1.0:

权重top-5
text 1.0 / vec 0.75 > 4 > 12 > 11 > 8
text 0.7 / vec 1.04 > 5 > 8 > 11 > 12

纯文本头名 id 12 掉到第 5。 官方的调参建议是「start equal, then reduce whichever branch produces more false positives on your data」,并且明确要求 Tune weights against a labelled query set rather than by intuition——对着标注集调,别凭直觉。这条和 多路检索那篇 里「权重必须来自分桶评估」是同一句话。

7.3 分支 LIMIT:这不是调参,是硬上限​

The fused result can only contain rows that at least one branch returned. A row missing from both candidate lists cannot be recovered later, so the branch LIMIT sets a hard ceiling on quality.

两路都没召回的文档,后面救不回来。 官方建议每路取目标条数的 1020 倍(top-10 就取 100200)。而且因为下推,这个放大倍数的代价很小——§六 已经证明了。

7.4 tiebreaker:必须加,而且必须是被索引的列​

官方要求两个分支都在排序键后面补 , id。我实测去掉之后会出并列:

id | rank
----+------
12 | 1
11 | 2
4 | 3
5 | 4
8 | 4 <- id 5 和 id 8 并列第 4

RANK() 给并列同行号,于是 LIMIT 正好切在并列处时,谁进候选集是任意的。官方原话:

Without a tiebreaker, which of the tied rows survive is arbitrary, so the same query can return different candidates from one run to the next — and the fused result changes with them.

同一条查询,两次跑可能给出不同的 top-k。 向量列重复值多的时候(比如多个文档共用同一段文本的 embedding)尤其容易撞上。这个坑不报错、只是结果漂移——最难查的那一类。

八、三个我踩到的坑​

坑 1:pdb.score() 必须和 ParadeDB scan 在同一层​

我第一版把分支包成子查询,再在外层取分数,直接报:

ERROR: Unsupported query shape. Please report at https://github.com/paradedb/paradedb/issues/new/choose

外层 SELECT 里对子查询结果调 pdb.score(id) → 查不动。要拿分数就在最里层 SELECT 里拿。

说句公道话:这个报错信息给了 issue 链接、也算是对上号了,但 Unsupported query shape 没告诉你是哪个 shape 不支持的。我在这一步上试了三轮才定位到「score 的调用层级」——这类「限制边界不清」的报错,是新生项目最常见的体验成本。

坑 2:tiebreaker 列不在索引里,TopK 优化直接失效​

我加了个不在索引里的 rating 列当 tiebreaker,ParadeDB 自己打了警告(逐字):

WARNING: Query has LIMIT 5 but is not using Top K scan (using Normal instead). Reason: only partial prefix of ORDER BY can be pushed down (1 columns matched). This may cause poor performance on large datasets.

执行计划从 TopKScanExecState 退化成 NormalScanExecState + 外层 Sort。把 tiebreaker 换回索引内的 id,TopK 立刻回来。

这条值得表扬:它没静默降级,而是带着原因和补救建议直接警告你(还给了 SET paradedb.planner_warnings = 'off' 的关掉方式)。对照本站 pgvector 篇里那条「后过滤静默返回 0 条」,这两种失败方式的体验差距是实质性的——一条会告诉你,一条不会。

坑 3:向量检索是 beta​

官方在 RRF 页和 Hybrid 页都挂了这条 Note(逐字):

This section uses vector search, a beta feature available in versions 0.25.0 and above.

上生产前把版本钉死。 pg_search 的 access method 名、选项白名单都在变(§三、§九 各有一个例子),跨版本升级要读 changelog。

九、中文场景:||| 默认切中文会出事,要用 jieba​

中文用户做混合检索,第一件要处理的是分词。ParadeDB 提供 jieba 分词器,得写成索引表达式:

CREATE INDEX cn_idx ON cn_docs
USING paradedb (id, (body::pdb.jieba), embedding vector_cosine_ops)
WITH (key_field='id');

实测能用,SELECT '南京市长江大桥'::pdb.jieba::text[] 的输出和官方文档的 Expected Response 逐字一致:

{南京,京市,南京市,长江,大桥,长江大桥}

(注意 search_mode 默认开启——它在保留完整词的同时把复合词也切碎,所以输出里有 南京市 也有 南京。这是好是坏取决于你要不要避免「只命中复合词的一半」,见下面那条不一致。)

中文关键词检索 + RRF 融合在同一条链路里跑通:

--- jieba 分词器下的中文关键词检索 ---
id | body | bm25
----+------------------------------+--------
2 | 订单服务超时排查手册 | 0.3913
1 | 消息队列积压导致订单服务超时 | 0.3414

--- 同一张表:关键词 + 向量的 RRF 融合 ---
id | body | rrf_score
----+------------------------------+------------
2 | 订单服务超时排查手册 | 0.02768377
1 | 消息队列积压导致订单服务超时 | 0.02760444

但这里有一处官方文档与实测不一致,值得单独记一笔。

官方 jieba 页有一节写「要只保留完整词,设 search_mode=false」,示例是:

SELECT '南京市长江大桥'::pdb.jieba('search_mode=false')::text[];

在 pg_search 0.25.10 上,这个写法和它的三种变体全部失败(四种写法的完整输出在 source/paradedb/run4.log):

ERROR: Invalid option: 'search_mode'. Allowed options: alias, alpha_num_only, ascii_folding,
b, chinese_convert, columnar, fieldnorms, k1, lowercase, normalizer, remove_long,
remove_short, stemmer, stopwords, stopwords_language, trim.
ERROR: type modifiers must be simple constants or identifiers -- 无引号写法
ERROR: Option '{"search_mode": false}' is not allowed at position 0 -- JSON 对象写法

search_mode 不在允许的选项白名单里。 所以:默认开启这件事与实测相符,但按文档关掉做不到。

免责要写清楚:我只测了 0.25.10 一个版本。可能是文档超前于已发布版本,也可能是该选项在别处配置。本文只陈述「按文档写法在 0.25.10 上做不到」这一可复现事实,不推断官方写错了。

这也是本站一直在强调的那条:引用外部资料必须核对一手来源,而一手来源也需要在自己的版本上验证一次。文档是权威,但文档描述的是某个版本——两者之间隔着一个版本号。

十、横向对比:三条路的融合层归谁​

融合怎么来索引形态
ParadeDB自己写 CTE + RANK() + sum(weight),无内置函数一个 paradedb 索引同时装文本列和向量列(用 pgvector 类型,不用它的 HNSW/IVF)
Milvus内置 RRFRanker(),hybrid_search() 一行出结果稀疏 + 稠密各自独立索引
裸 pgvector自己写 SQL 融合(pgvector 入门 §6)向量 HNSW/IVFFlat + 另一个 GIN/GiSt 文本索引,两套

「融合归谁」决定了你调什么: Milvus 里你调的是 RRFRanker(k=…) 的参数;ParadeDB 里你调的是一整段 SQL。可调空间大得多(分支 LIMIT、并行策略、过滤条件、加第三路、融合后接 rerank 都在你手里),代价是这些都得你自己写对——尤其是 §八 那两个不报错的坑。

ParadeDB 真正的差异化在索引层,不在融合层:一个索引、一趟扫描、过滤条件和两路检索可以同时下推。实测 WHERE category @@@ 'Footwear' ORDER BY embedding <=> '…' LIMIT 5 依然是一个 TopKScanExecState,过滤条件(Tantivy Query)就在同一个 scan 里。

这一点在选择性过滤的场景下价值最大——对照本站 pgvector 篇那个「后过滤静默返回 0 条」的坑:pgvector 是「先 ANN 取 K 条、再过滤」,过滤太狠就什么都不剩;而 ParadeDB 是过滤条件和向量排序在同一个索引 scan 里完成。这是架构差异,不是参数问题。

十一、局限(引用本文数字前请一并引用)​

  1. 12 行玩具数据,只验证机制,不构成性能基准。本文刻意没给延迟/吞吐数字——在这个量级上测不出意义,给了反而是误导。
  2. 向量只有 4 维、且是构造的单平面数据,不代表真实 embedding 的行为。特别是「向量并列多」这个现象,真实数据里远比这里频繁,§7.4 的 tiebreaker 坑在真实数据上只会更严重。
  3. 只测了 pg_search 0.25.10 一个版本,且向量检索是 beta。§三 的 access method 名、§九 的选项白名单都是版本相关的事实。
  4. §7.1 的「k=10 到 k=1000 排序一致」是本数据集上的观测,不是普适规律。
  5. 我没测多表 RRF(官方给了跨表变体,且明确说跨表直接 sum(pdb.score) 效率不行、指向 Issue #5301)。那是另一条链路,本文没覆盖。

十二、落地清单​

  1. 先确认版本:USING paradedb(不是 bm25)、向量检索需要 0.25.0+ 且为 beta。把版本钉在部署里。
  2. 一个索引装全:文本列 + 需要过滤的列 + 向量列(带正确的 vector_*_ops)放进同一个 USING paradedb 索引。算子与 opclass 必须匹配,否则静默退化。
  3. RRF 自己写,但照官方形状写:每路 ORDER BY … LIMIT + RANK() 窗口 + sum(weight)。
  4. 两个分支都加 tiebreaker,且必须是索引内的列——列不在索引里会掉 TopK 并警告。这是唯一会让结果漂移的坑,优先修。
  5. 过滤条件两边都加,保证两路从同一批候选里排名次。
  6. 分支 LIMIT 取目标条数的 10~20 倍,因为下推所以便宜。这是硬上限,不是调参项。
  7. k 保持 60,把调参精力放在权重上,且权重必须来自标注集。
  8. 中文列用 (col::pdb.jieba) 建索引表达式,别指望默认分词器。
  9. 用 EXPLAIN 验收:每路都应该看到 TopKScanExecState。看不到就是没走优化路径。
  10. 自己搭一套评测再谈调参——本站 多路检索那篇 的核心教训是「均值骗人,必须逐查询 + 分桶 + 做通道归因」,这条对 ParadeDB 一样成立。

十三、一句话收尾​

ParadeDB 把「两路怎么各自取到候选」做进了索引,把「两路怎么合成一份排名」留给了 SQL。

这个分工是好是坏,取决于你缺的是什么:缺索引层的统一(少一套索引、少一次回表、过滤和检索同趟完成),它的价值是实的;缺开箱即用的融合(像 Milvus 那样一行 hybrid_search()),那它一点都没给。

能确认的是:融合这段代码你必须自己写对,而它有两个不报错的坑——一个让结果漂移(tiebreaker),一个让性能静默退化(tiebreaker 不在索引里)。这两条在文档里都有,但都藏在细节段落里,值得先看一遍再动手。

关联​

参考​

  • 官方文档(2026-09-25 抓取,Mintlify .md 端点,逐字引用均出自此处,留档在 source/paradedb/official-*.md):
    • RRF 全页(公式、SQL 形状、k/权重/分支 LIMIT 的取舍、EXPLAIN 验收):https://www.paradedb.com/docs/reference/hybrid/rrf
    • 为什么按位次融合:https://www.paradedb.com/docs/concepts/hybrid/overview
    • 向量索引与「用 pgvector 类型、不用其 HNSW/IVF」:https://www.paradedb.com/docs/reference/indexing/indexing-vectors
    • 距离算子与 opclass 对应表:https://www.paradedb.com/docs/reference/vector/querying
    • 跨表 RRF 变体与 Issue #5301:https://www.paradedb.com/docs/reference/full-text/score
    • jieba 分词器:https://www.paradedb.com/docs/reference/tokenizers/available-tokenizers/jieba
  • 抓取方式:curl -sS -L。不要用 WebFetch——它对部分域名做安全校验会失败(提示 Unable to verify if domain … is safe to fetch),这是工具自身的限制,不是网络不通,换 curl 一次就通。
  • 实测脚本与真实输出:source/paradedb/(rrf_lab.sql / run.log、rrf_lab2.sql / run2.log、jieba.sql / run3.log、jieba2.sql / run4.log)
  • 实测环境:PostgreSQL 18.6、pg_search 0.25.10、pgvector 0.8.4,镜像 paradedb/paradedb:latest,docker 容器 pdb-rrf。本文所有数字均出自上述日志。
  • RRF 原始论文:G. V. Cormack, C. L. A. Clarke, S. Büttcher, Reciprocal rank fusion outperforms condorcet and individual rank learning methods, SIGIR 2009, pp. 758–759, DOI 10.1145/1571941.1572114。本文未复现该论文的数值,只引用其结论。