混合检索选型:BM25 与向量该放进哪个库
关键词和语义两路检索可以合并在 PostgreSQL、Elasticsearch 或 Milvus 中,选型重点是检索能力、数据同步与运维边界。
混合检索选型:BM25 与向量该放进哪个库
修订说明(核实于 2026-08-13,东八区) 本篇按 ParadeDB / PostgreSQL / Milvus 官方文档复核过,主要修正:
- ParadeDB 查询语法过时:评分函数是
pdb.score(id)不是paradedb.score(id);基础匹配用|||(OR)/&&&(AND),@@@是 query builder 用法。- 关键词 CTE 少了顶层
ORDER BY,LIMIT 20拿到的不保证是 BM25 前 20 名。pdb.jieba已确认是官方支持的 tokenizer,原文的「拼写未确认」提示已撤下。- 能力对比表中 PG「高亮/纠错/联想弱」已过时:pg_search 现已支持高亮、模糊查询、聚合、自动补全。
- 规模上限、「PG 一把梭」等结论补上了适用前提。
以下两条经核对,原文是对的,报告的质疑不成立,予以保留:
USING paradedb是 0.25.0 起的正式语法(USING bm25降级为兼容别名),不是笔误。- Milvus 2.5 的全文检索确实集成 Tantivy,这是官方博客明确写的。
锁定版本:ParadeDB
pg_searchv2 API(0.25.0+)、PostgreSQL 16+、Milvus 2.5+。
阅读提示 本笔记的前置阅读是 BM25 算法原理(BM25 怎么算分)和 RAG概念(为什么 RAG 需要两路召回)。那两篇解决「是什么」,本篇只回答一个选型问题:关键词一路和语义一路,应该放进哪个存储,代价最小。
默认读者已经理解稀疏检索与稠密检索的区别,正在为一个真实项目决定要不要引入 ES 或 Milvus。
本篇不讲 BM25 公式推导,不讲切块与 Embedding 模型选择(见 RAG优化方案),不讲系统怎么分层(见 RAG架构设计)。本篇只管检索能力放在哪个进程里。
⚠️ = 常见陷阱 🆚 = 对比说明 💡 = 机制或选择建议
目录
- 一、这个选型问题是怎么冒出来的?
- 二、说「PG 不支持中文 BM25」时,说的是哪一层?
- 三、混合检索到底指什么?
- 四、三个候选方案差在哪?
- 五、PG 方案怎么落地?
- 六、常见陷阱
- 七、什么时候该跨过那条线?
- 速查表
- 复习重点
核心概念 BM25 曾经是「必须上 Elasticsearch」的主要理由——因为只有它有。现在 PostgreSQL(pg_search)和 Milvus(2.5+)都把 BM25 内置了,于是选型问题从「要不要上 ES」变成了「把关键词和语义两路合并到哪个进程,运维代价最小」。对中小项目,答案通常是 PG:
pgvector管语义,pg_search管关键词,两者都是扩展,不新增服务、不搬数据。
一、这个选型问题是怎么冒出来的?
三五年前的标准架构是三个库拼起来:PG 存业务数据,ES 做关键词检索,向量库做语义检索。
拼三个库的直接原因是能力不可替代:BM25 只有 ES 有,向量索引只有向量库有。你没得选。
现在这个前提塌了。三家都在往对方的地盘扩:
- PostgreSQL 装
pg_search就有了 BM25,装pgvector就有了向量索引; - Elasticsearch 8.0 起支持 HNSW 向量检索;
- Milvus 2.5 起内置 BM25 全文检索。
于是「BM25」不再是任何一家的独占卖点,选型的判断依据必须换一个——换成运维边界:这个能力是跑在你已有的进程里,还是要新起一个集群。
为什么判断依据是「进程」而不是「性能」 中小项目的检索性能瓶颈几乎从来不是引擎本身,而是数据同步:ES/Milvus 的数据要从 PG 同步过去,于是你要处理同步延迟、失败重试、双写不一致、全量重建。这些工作量通常远超「换个更快的引擎」带来的收益。 能力差不多的时候,选进程数少的那个。
二、说「PG 不支持中文 BM25」时,说的是哪一层?
这句话在网上到处都是,但它同时是对的和错的——取决于问的是哪一层。分成三层就不会再混。
图:PG 检索能力的三层边界——裸核、扩展、外部中间件,以及运维成本的真正分界线。
图从左到右读:左边两层都在同一个 PG 进程内,红色虚线右边才是独立服务。「少维护一个中间件」省下的是跨过虚线时才产生的那三项成本(部署监控、数据同步、无法 JOIN),而不是省下某个算法。
三层的具体能力:
| 层 | 装什么 | 有 BM25 吗 | 切中文吗 | 运维成本 |
|---|---|---|---|---|
| 裸核 Built-in | 什么都不装 | ✗ 只有 ts_rank |
✗ 整句当一个 token | 0 |
| 扩展 Extension | CREATE EXTENSION pg_search |
✓ 真 BM25 | ✓ 自带 jieba | 几乎 0,仍是一个进程 |
| 外部中间件 | 部署 ES / Milvus | ✓ | ✓(需装插件) | 一整套集群 |
所以准确的说法是:
PG 裸核既没有 BM25 也不切中文;但装上
pg_search扩展后,BM25 和中文分词都在 PG 进程内部解决,不需要外部中间件。
裸核的
ts_rank差在哪 常见的说法是「ts_rank词频线性叠加、不做长度归一化」——这是不准确的。PG 对同一个词的第 j 次出现按1/(j+1)²衰减累加(见源码src/backend/utils/adt/tsrank.c的calc_rank_or;这是实现细节,官方文档没有写成公式,跨大版本前值得再确认一次),这个级数收敛,饱和其实比 BM25 还激进;文档长度归一化也有,就是ts_rank的第四个参数(normalization 位掩码),只是默认关闭。它真正的两个短板是:
- 没有 IDF——不知道「的」是废词、「Tantivy」是稀有词,缺少全库统计;
- 无法在索引层早停——必须扫完所有命中行才能排序,拿不到 top-K 提前退出。
知道真实短板才能做对决策:这两点都是索引结构决定的,写个自定义打分函数补救不了,只能换引擎。
三、混合检索到底指什么?
混合检索(Hybrid Search)是一种思想,不是某个产品的功能。 只要同时用了关键词和语义两路召回并做了融合,就叫混合检索,跟在哪个库里实现无关。
图:关键词一路走分词与倒排索引、由 BM25 打分;语义一路走 Embedding 与 HNSW、由向量距离打分;最后用 RRF 按名次融合。
图从上往下读:查询进来后分叉成两条独立的路,各自用完全不同的索引结构和打分体系跑完,最后在底部融合。关键结论是最下面那行——两路都命中的文档会排到最前,这正是混合检索的全部价值。
为什么单独一路都不够:
- 只用向量:问「公司 2024 年 Q4 财报营收多少」,
2024年Q4这种精确 token 向量不敏感,容易召回 2023 年的。 - 只用 BM25:问「赚钱最多的是哪个季度」,BM25 认不出
赚钱最多和营收最高是一回事。
RAG 场景两种问法都会出现,所以混合检索值得作为基线去评测。注意这是「值得先试」,不是「必然更好」:如果你的查询高度同质(比如全是自然语言长问句,几乎没有专有名词和编号),单路向量检索可能已经够用,加一路 BM25 只是徒增复杂度。用自己的标注集比一次,让数据来定。
RRF 里的 60 是什么 RRF(Reciprocal Rank Fusion,倒数排名融合)的公式是
Σ 1/(k + rank),k取 60 是原论文的经验值。它的作用是压平相邻名次的差距:
1/(60+1)和1/(60+2)只差 1.6%,所以第 1 名和第 2 名几乎等价。⚠️ 但别推过头——RRF 依然关心名次,只是不关心「差一两名」这种细节。拉开距离看就很明显:
名次 得分 1/(60+rank)相对第 1 名 1 0.0164 100% 5 0.0154 94% 20 0.0125 76% 100 0.0063 38% 第 100 名只有第 1 名的 38%,差距是实打实的。准确的说法是:RRF 对头部的微小名次波动不敏感,但对「排在前面还是后面」依然敏感;两路都命中的文档能拿两份分,所以通常会浮上来——这是倾向,不是硬规则,单路的绝对头名照样可能赢过双路的中游。
它鲁棒的真正原因是根本不用分数,只用名次。余弦距离(0
2)和 BM25 分(0几十)是两套不可比的量纲,直接加权相加需要反复调参,换个数据集就失效;换成名次就没有这个问题。
四、三个候选方案差在哪?
三家都能做混合检索,差别在你要为此维护几个进程,以及检索结果能不能直接和业务数据 JOIN。
| 维度 | PG(pgvector + pg_search) | Elasticsearch | Milvus |
|---|---|---|---|
| 关键词一路 | pg_search(Tantivy 内核) |
原生 BM25,本行 | 2.5+ 内置 sparse-BM25(同样是 Tantivy) |
| 语义一路 | pgvector(HNSW) |
8.0+ 支持 HNSW | 本行,最专业 |
| 融合方式 | 自己写 RRF(SQL CTE) | rank: { rrf: {} } |
hybrid_search + RRFRanker |
| 新增进程数 | 0 | 1 套集群 | 1 套集群 |
| 数据同步 | 不需要 | 需要,有延迟和不一致 | 需要 |
| 能否 JOIN 业务表 | 能,就是普通 SQL | 不能,要回查 PG | 不能 |
| 权限过滤 | SQL WHERE 直接下推,可用 RLS |
需把 ACL 同步过去并保证及时失效 | 同左,靠 scalar filter |
| 高亮/纠错/联想 | 已支持(pdb.snippet()、模糊查询、聚合、自动补全) |
开箱即用,生态最全 | 弱 |
| 规模上限 | 见下方说明 | 见下方说明 | 见下方说明 |
⚠️ 「规模上限」这一格没有可靠答案,别当容量规划用 原文写的是「PG 千万级 / ES 亿级 / Milvus 亿级最强」。这组数字缺少全部关键前提:向量维度、索引类型与参数、单机还是集群、内存多大、可接受的召回率和 P99 延迟、写入 QPS。任何一项变化都能让「上限」挪动一个数量级——同样是 1000 万条,768 维 HNSW 全内存和 1536 维磁盘索引完全是两回事。
能说的只有定性结论:
- PG + pgvector 的 HNSW 索引需要常驻内存,内存是先撞到的墙,通常比 Milvus 更早;
- Milvus 从设计上就为分片和横向扩展做了准备,规模越大优势越明显;
- ES 在关键词一路成熟,向量一路是后加的,超大向量规模下不如 Milvus。
真要定容量,只有一条路:拿自己的数据和维度做压测。 网上任何「PG 撑得住 N 条」的说法都默认了一组你不知道的前提。
PG 的「进程」说法要精确一点 表里「新增进程数 0」指的是不新增独立的数据服务——不用再部署、监控、备份第二套系统,也不用写同步管道。
但 PostgreSQL 本身是多进程架构(每个连接一个 backend 进程,外加若干后台进程),
pg_search和pgvector是加载进这些进程的扩展,不是「所有东西跑在同一个进程里」。这个区别在做资源规划时有意义:扩展的内存和 CPU 开销会落在 PG 的进程里,和你的业务查询抢同一份资源。检索负载重时,该考虑读副本隔离,而不是以为「反正没新增进程所以没成本」。
💡 三句话版本:
- ES 是靠 BM25 吃饭的老牌搜索引擎,强在检索周边功能(高亮、聚合、纠错);
- Milvus 是靠向量吃饭的,把 BM25 请来当副手,强在向量规模;
- PG 是为了让你不必引入前两者,把两样都搬进了自己进程里,强在零同步、能 JOIN。
💡 怎么选
- 已经在用 PG,检索规模在自己压测能撑住的范围内,团队没有专职运维 → 先用 PG 试。对多数「已有 PG + 没有专职检索团队」的项目,这是成本最低的起点——但它是起点假设,不是普适结论,仍要用自己的数据验证。
- 已经有在跑的 ES 集群 → 直接在 ES 里加向量字段,别再引入第三个库。
- 向量规模大到
pgvector的 HNSW 索引装不进内存,或压测显示 P99 延迟稳定超出你的预算 → 向量那一路拆到 Milvus,关键词一路可以留在 PG。(判断依据是你自己压测的结果,不是某个固定的条数或毫秒数。)注意这三条的判断依据都是你已有什么和规模,没有一条是「谁的 BM25 更好」——那已经不再是区分点了。
五、PG 方案怎么落地?
⚠️ 本节代码有版本敏感性 ParadeDB(
pg_search)的 API 经历过至少两次不兼容大改(0.15 前后、v2 API)。下面按 v2 API(0.25.0+) 写:索引访问方法自 0.25.0 起正式更名为paradedb,USING bm25保留为向后兼容别名。
pdb.jieba是官方支持的中文 tokenizer(0.21.0 起还加了繁简转换),不是笔误。同系列还有pdb.chinese_lindera(词级)和字符级的chinese_compatible可选。仍然建议:执行前用目标版本跑一遍并看
EXPLAIN,确认索引真的被用上了而不是退化成顺序扫描。
1. 装扩展
CREATE EXTENSION vector; -- 语义一路
CREATE EXTENSION pg_search; -- 关键词一路(BM25)2. 建表与两套索引
CREATE TABLE docs (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(768) -- bge-base-zh 是 768 维
);
-- 语义索引
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);
-- 关键词索引:索引类型现已更名为 paradedb,分词器用 cast 语法指定
CREATE INDEX docs_bm25 ON docs
USING paradedb (id, (content::pdb.jieba))
WITH (key_field = 'id');写入时只写原文和向量,pg_search 会自己维护倒排索引:
INSERT INTO docs (content, embedding) VALUES ($1, $2);3. 混合查询:用 CTE 手写 RRF
PG 目前没有 Milvus 那样的原生 hybrid_search,两路要自己融合。下面是能跑的骨架:
WITH semantic AS ( -- 语义一路,取 top 20
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM docs ORDER BY embedding <=> $1 LIMIT 20
),
keyword AS ( -- 关键词一路,取 top 20
SELECT id, ROW_NUMBER() OVER (ORDER BY pdb.score(id) DESC) AS rank
FROM docs
WHERE content ||| $2 -- ||| = 匹配任一词(OR)
ORDER BY pdb.score(id) DESC -- ← 必须有,否则 LIMIT 取的是任意 20 行
LIMIT 20
),
fused AS (
SELECT
COALESCE(s.id, k.id) AS id,
COALESCE(1.0 / (60 + s.rank), 0) -- 未命中的一路贡献 0
+ COALESCE(1.0 / (60 + k.rank), 0) AS rrf_score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id -- 必须 FULL,两路地位对等
)
SELECT d.*, f.rrf_score
FROM fused f JOIN docs d ON d.id = f.id
ORDER BY f.rrf_score DESC
LIMIT 10;四个关键点,也正是最容易写错的地方:
FULL OUTER JOIN——只被一路命中的文档必须保留,见下面的陷阱 3。COALESCE(..., 0)——未命中的一路应当贡献 0 分,而不是让整个表达式变NULL。- 先融合再回表——
fused只处理 ID 和分数,最后才 JOIN 回docs取完整行,避免在两路 CTE 里重复搬运大字段。 - 两路 CTE 都必须有顶层
ORDER BY——见下面的陷阱 0。
⚠️ 陷阱 0:
ROW_NUMBER() OVER (ORDER BY ...)不等于ORDER BY这是最隐蔽的一个,原文的关键词 CTE 就踩了:-- ❌ 错的 SELECT id, ROW_NUMBER() OVER (ORDER BY pdb.score(id) DESC) AS rank FROM docs WHERE content ||| $2 LIMIT 20窗口函数里的
ORDER BY只决定rank这一列怎么编号,不决定结果集按什么顺序返回。 没有顶层ORDER BY时,LIMIT 20从满足WHERE的行里取任意 20 行——具体取哪些取决于执行计划,可能是索引扫描顺序,也可能是堆表物理顺序。后果特别难查: 你确实拿到了 20 行、每行也确实有 1~20 的 rank,看起来完全正常。但这 20 行是全体命中文档里的任意 20 个,BM25 真正的第 1 名很可能根本不在里面。融合结果因此持续偏差,而 SQL 不报任何错。
正确写法: 顶层补上
ORDER BY pdb.score(id) DESC,再LIMIT 20。语义一路(ORDER BY embedding <=> $1 LIMIT 20)本来就是对的,容易让人误以为两路对称——它们不对称,因为语义那一路的ORDER BY写在了外层。
六、常见陷阱
⚠️ 陷阱 1:用 pg_trgm 做中文搜索 错误操作: 看到教程说「PG 装
pg_trgm就能支持中文搜索」,于是建了 GIN trigram 索引。实际结果:
LIKE '%关键词%'确实变快了,但搜「全文检索」时,「检索全文」「全文检索技术」「检索」得分几乎一样,排序完全没有相关性可言。原因:
pg_trgm是三元组模糊匹配,把字符串切成三字符片段算重合度,用途是容错(potos能搜到photos)和加速LIKE。它能给出一个排序——
similarity(a, b)和<->距离算子都可以拿来ORDER BY。但那是字符串表面相似度,回答的是「这两串字长得像不像」,不是「这篇文档对这个查询有多相关」。它不理解「词」的概念,没有 IDF(不知道哪个词稀有),没有文档长度归一化,也不区分命中的是标题还是正文。中文场景更糟:三字符窗口横跨词边界,「北京大学」和「京大学生」的 trigram 重合度很高,但语义无关。准确的说法是:
pg_trgm做的是相似度排序,不是相关性排序。这两件事在英文短字段(人名、商品名)上偶尔能凑合,在中文长文检索上完全不能替代 BM25。正确做法: 要相关性排序就用
pg_search(BM25 + 倒排索引)。pg_trgm保留给拼写容错和模糊匹配场景,两者可以共存,各管各的。
⚠️ 陷阱 2:照抄网上的
paradedb.create_bm25()错误操作: 按中文教程执行CALL paradedb.create_bm25(index_name => ..., text_fields => '{...}'),然后用SELECT * FROM posts_bm25 WHERE posts_bm25 @@@ '...'查询。实际结果: 报错,函数不存在;或在旧版本上能跑,升级后整套查询失效。
原因: 这是 ParadeDB 0.x 时期的 legacy API(把索引名当表查的 schema-based 模式),早已废弃。官方文档里这部分已经挪到
/legacy/路径下了。中文社区的教程绝大多数停留在这一代。正确做法: 用标准
CREATE INDEX ... USING paradedb (...) WITH (key_field='id'),查询直接查原表:SELECT * FROM docs WHERE content ||| '关键词'。以及一条通用习惯——pg_search相关的写法一律以官方文档为准,不要信搜索引擎排在前面的中文博客,这个扩展的 API 变动频率远高于一般 PG 扩展。
⚠️ 陷阱 3:RRF 用了 INNER JOIN 错误操作: 融合两路时写成
FROM docs JOIN semantic USING (id) FULL OUTER JOIN keyword USING (id)。实际结果: 两种失效叠加——① 被 BM25 命中但没进向量 top-20 的文档被 INNER JOIN 直接丢弃;② 侥幸留下的行里
rank为NULL,而1.0/(60 + NULL)结果是NULL,整个rrf_score变NULL,排序彻底失效。原因: 混合检索的两路地位是对等的,任何一路单独命中的文档都必须进入候选集——这恰恰是做混合检索的全部意义。用 INNER JOIN 等于宣布「向量没召回的一律不要」,那还不如只用向量。而
NULL参与算术运算结果恒为NULL,是 SQL 三值逻辑的基本行为。正确做法: 两路之间用
FULL OUTER JOIN,每一路的贡献用COALESCE(1.0/(60+rank), 0)兜底,融合完再 JOIN 回主表。见上一节的完整 SQL。
⚠️ 陷阱 4:为「以后可能要扩」提前上 ES 错误操作: 项目只有几十万文档,但担心将来长到亿级,一开始就架 ES 集群。
实际结果: 立刻要处理 PG→ES 的数据同步(延迟、失败重试、双写不一致、全量重建),权限数据要额外同步一份到 ES 并保证及时失效。这些成本从第一天就产生,而 ES 的能力在几十万文档规模下完全用不上。
(注:「检索结果还得回 PG 捞完整行」不是必然的——ES 默认保存
_source,可以直接返回原始文档。真正跑不掉的是同步:一旦文档在 PG 里更新或权限变更,ES 那份副本就进入了不一致窗口。你要么接受这个窗口,要么为它写一套对账逻辑。)原因: 这是把未来才产生的收益换成了当下就要付的成本。而且假设本身站不住——从 PG 迁到 ES 并不需要重写业务逻辑,检索层本就该被封装在一个接口后面(见 RAG架构设计)。
正确做法: 从
pgvector + pg_search起步,把检索封装成一个可替换的接口。等真正撞到规模瓶颈(见下一节的具体信号)再迁——那时你还多了一份真实的线上查询模式数据,迁移方案反而更准确。
七、什么时候该跨过那条线?
「多少万行该上 ES」是个没有标准答案的问题——行数从来不是真正的触发条件,并发量、字段数、是否要聚合才是。与其记一个拍脑袋的阈值,不如记这些可观测的信号:
| 信号 | 说明 | 该往哪走 |
|---|---|---|
| 索引重建时间超出维护窗口 | 单次全量重建要几小时 | 考虑外部引擎 |
| BM25 查询 p99 持续超标 | 加索引、调参都压不下来 | ES |
| 需要高亮、拼写纠错、搜索联想 | PG 生态在这块确实弱 | ES |
| 需要复杂聚合分析(facet、时间序列下钻) | ES 的强项 | ES |
| 向量规模上亿,或延迟要求 20ms 内 | pgvector 的 HNSW 会吃紧 |
Milvus |
| 检索负载明显影响业务库 | 读写争抢同一个实例 | 先试只读副本,再考虑拆分 |
💡 拆分不必一次拆完 两路是可以分开迁的。最常见的渐进路径是:向量那一路先拆到 Milvus(因为向量规模先撑不住),关键词一路继续留在 PG。融合逻辑本就在应用层,两路来自不同的库并不影响 RRF——RRF 只需要两个名次列表。
这也反过来说明了陷阱 4为什么不成立:迁移是可以切片进行的,不需要一开始就赌。
速查表
| 我想要 | 用什么 | 不要用 |
|---|---|---|
| 中文关键词相关性排序 | pg_search + jieba 分词器 |
pg_trgm(不做相关性) |
拼写容错、模糊 LIKE 加速 |
pg_trgm |
pg_search(不是它的活) |
| 语义/同义表达召回 | pgvector + HNSW |
任何关键词方案 |
| 两者都要(RAG 常见基线) | pgvector + pg_search + 手写 RRF |
未经评测就断定只需一路 |
| 高亮、纠错、联想、复杂聚合 | ES 生态最全;pg_search 也已支持高亮/模糊/聚合 |
PG 裸核 |
| 向量规模超出单机内存 | Milvus | pgvector(HNSW 需常驻内存) |
| 名词 | 一句话 |
|---|---|
| BM25 | 关键词相关性打分公式,k1 管词频饱和,b 管长度惩罚,详见 BM25 算法原理 |
| Tantivy | Rust 写的全文检索库,受 Lucene 启发(不是移植);pg_search 和 Milvus 2.5 的 BM25 都用它 |
| 稀疏向量 | BM25 的另一种表示形式,绝大部分维度为 0,配倒排索引使用 |
| 稠密向量 | Embedding 模型的输出,每一维都有值,配 HNSW/IVF 使用 |
| RRF | 按名次而非分数融合多路结果,Σ 1/(60+rank) |
||| / &&& |
pg_search v2 的 BM25 匹配算子,分别是「命中任一词(OR)」和「全部命中(AND)」。@@@ 用于 query builder 等复杂查询;@@ 则是 PG 裸核的 tsquery 匹配,三者别混 |
pdb.score(id) |
pg_search v2 的 BM25 打分函数(旧写法 paradedb.score() 已更名) |
复习重点
- BM25 已经不是选型的区分点。 PG、ES、Milvus 三家都有了,判断依据要换成运维边界:这个能力跑在你已有的进程里,还是要新起一个集群。
- 「PG 不支持中文 BM25」只在裸核那一层成立。 装上
pg_search后,BM25 和 jieba 中文分词都在 PG 进程内解决,不新增服务、不搬数据。- 裸核
ts_rank的真实短板是没有 IDF、无法索引层早停,不是「词频线性叠加」——它其实有1/(j+1)²衰减。这两个短板由索引结构决定,写函数补救不了。- 混合检索是思想不是功能。 关键词一路认字、语义一路认意思,RAG 两种问法都会遇到,所以两路都得要。
- RRF 只用名次不用分数,这是它能融合两套不可比量纲的原因;
k=60的作用是压平头部差距。- 融合时两路必须
FULL OUTER JOIN+COALESCE兜底,用 INNER JOIN 会丢掉单路命中的文档,NULL参与算术会让排序失效。- 中小项目默认答案:
pgvector+pg_search一把梭,把检索封装成可替换接口,等撞到具体信号(重建超窗口、p99 超标、需要高亮聚合、向量上亿)再迁,而且可以两路分开迁。pg_search的 API 变动频繁,paradedb.create_bm25()已废弃,一律以官方文档为准,别信中文博客。