☰
别再急着上向量数据库:PostgreSQL+pgvector+BM25混合检索实践
2026/10/11 23:15:34 网站建设 项目流程

去年我们团队给 RAG 系统选型时,差点就按主流路径下单了一个专用向量数据库。年费报上来那一刻,我盯着预算表沉默了:当时的切片总量只有几十万条,真正在服务的业务也就一条产品线,买专用向量库的预算足够我们把主库 PostgreSQL 实例升两个档位还有富余。后来我们决定自己动手,把所有数据放进基于 pgvector + BM25 的混合检索系统里,跑了几个季度,召回效果和系统开销都在可控范围内。这里把选型逻辑、落地步骤和踩过的坑整理出来,给正在纠结要不要上专用向量库的团队一个参考。

1. 为什么我放弃了专用向量数据库:算清楚这笔账再说

1.1 成本账:不要拿“一次性部署”和“年付账单”比

专用向量数据库的单体能力确实强,但绝大多数中小团队的业务规模根本用不满它。我们当时评估过三种方案:云上托管向量库、自建专用向量库、在现有 PostgreSQL 上加 pgvector 扩展。云上托管的价格最透明,但它是按实例规格和存储线性收费的,一个入门实例的月费通常就是一台高配 PostgreSQL 的月费,而我们的 QPS 峰值不过几十,绝大多数时间实例都在空转。

自建专用向量库看起来是在省软件许可费,但人力成本一点没省。团队里没人熟悉它的部署参数、监控指标、备份方式和索引重建策略,所有知识都要从头学。相比之下,PostgreSQL 团队里的人都会,pgvector 只是数据库里的一个扩展,不需要单独维护一套集群。

这不是说专用向量库永远不该买。如果知识库切片到了一千万条以上、在线检索 QPS 上了几千、需要复杂的水平扩展,专用方案是合理选择。但在那个规模之前,一套走 PostgreSQL 的方案在经济上是绝对占优的。我当时的判断标准就一条:把“年费账单”除以实际查询量,看看每千次查询到底花了多少钱。

1.2 运维账:多一个组件,就是多一个值班电话

搞 RAG 系统的人容易陷入“组件越多越专业”的误区。嵌入模型一套、向量库一套、全文检索一套、业务数据库一套,还没有正式上线,光维护工作就能把团队拖垮。

我们的实际情况是业务数据本来就在 PostgreSQL 里,RAG 需要用到用户权限、知识库归属、文档版本等业务字段,这些字段天然要和知识切片做关联。如果把切片放到单独的向量库,应用层就得维护两份数据的一致性:业务库里改了文档,向量库里的切片要不要跟着改?切片的元数据过滤逻辑放在哪边执行?

全部放到同一个 PostgreSQL 实例里之后,这个一致性难题直接消失了。切片表只是业务库中的一张普通表,文档更新时可以放在同一个事务里完成,备份恢复也只有一个数据库要管。我经常跟团队说一句话:轻量级系统不是功能少,而是组件少。PostgreSQL 在业务存储领域已经是被验证过的东西,再往上叠加 pgvector 和全文检索,只是给它增加功能,而不是给运维加新的值班电话。

1.3 能力边界:pgvector 不是万能,但足够撑起生产

做技术选型不能只看优点,还得知道底线在哪。pgvector 目前有几条明显的边界:

  • 它能管理的数据规模,远不如专用向量库。在百万级 768 维向量以内、QPS 数百以内,它表现得很稳;超过这个量级,索引内存占用和查询延迟就会开始吃力。
  • HNSW 索引的候选集过滤能力有限。如果查询里带复杂条件,比如“只搜索某租户最近 30 天的文档”,索引无法把过滤条件直接下推到图遍历过程,往往要先召回一批再过滤。
  • 不支持分布式扩展。跨多机扩展需要依赖 PostgreSQL 原生的复制、分区方案自己去设计。

但这些边界对大多数企业内部知识库来说都不算致命。我们实际要解决的问题是“如何在十万到百万级的切片里,快速找到和用户问题相关的几段内容”,pgvector 完全覆盖这个区间。

如果哪天业务真的涨到千万级向量,迁移到专用向量库也不是推倒重来。我们的数据模型、检索链路、评测集、RAG 应用代码都是现成的,届时只需要把存储层换掉。先低成本跑起来,再在需要时升级,这是我认为比较务实的路径。

2. 混合检索不是“两种结果拼一起”:BM25 与向量的互补逻辑

2.1 精确匹配与语义匹配,各管一段

如果只做向量检索,你会发现一个特别诡异的现象:数据库里明明有答案,但召回结果就是找不到。原因是嵌入模型对“字面精确匹配”天然不敏感,尤其以下场景:

  • 产品型号、故障代码、系统 ID,例如“TRX-900”“错误码 5072”
  • 制度条款编号,例如“数据安全管理办法 第十三条”
  • 行业黑话和缩写,例如“等保”“分类分级”“API 网关”
  • 人名、地名、系统名

这些问题在 RAG 知识库里太常见了。用户问“TRX-900 反复重启怎么办”,向量检索返回的是“设备重启”相关的语义文章,但专门描述 TRX-900 的那篇文档可能因为字面差异排到了很后面。关键词检索的好处就是它不管语义,它只管词面:文档里有没有“TRX-900”这个字符串?有就进候选集。这就是为什么混合检索不是锦上添花,而是兜底。

反过来也一样,只有关键词检索会漏掉大量语义复述。用户问“把企业数据放在公有云上要注意什么”,文档标题写的是“上云安全合规实践”,两者字面重合度极低,但语义高度相关,必须靠向量路把这篇文档捞出来。两路结合,本质上是把“精确匹配”和“语义召回”两个信号都交给排序系统,谁也别落下。

2.2 RRF:不调权重也能稳住融合效果

两路召回结果回来后,最朴素的想法是把分数归一化后加权相加。但 BM25 分数是词频相关的,可能从 0 到几十;向量余弦相似度是 -1 到 1。直接加权,结果会被分数范围大的一方主导。

实际推荐用 RRF(Reciprocal Rank Fusion,倒数排名融合),它的思路非常朴素:根本不看分数绝对值,只看排名位置。每个文档在两路召回里的排名分别记为 rank1 和 rank2,融合得分是:

score = 1 / (k + rank1) + 1 / (k + rank2)

k 常规取 60。很多开源检索引擎在实践里发现 k=60 是一个比较稳定的值,排名第 1 的得分最高,越靠后得分衰减越快。这个算法的优势在于:不需要调两个不同量纲分数之间的权重,也不用维护复杂的归一化逻辑。

举个例子:文档 A 在关键词路排名第 2,向量路排名第 10,RRF 得分约 1/62 + 1/70 = 0.0304;文档 B 只在向量路排名第 1,得分约 1/61 = 0.0164;文档 C 只在关键词路排名第 5,得分约 1/65 = 0.0154。最终 A 胜出,因为两路检索都认为它相关。RRF 把“多路共识”变成了排序信号,这才是混合检索的核心价值。

2.3 判断项目的检索结构:先看你的查询长什么样

做混合检索不要一上来就平均用力。先收集线上用户问题,按特征分类:

  • 如果大量问题包含编号、代码、产品名,关键词路的权重应该更重,混合结果基本靠 BM25 托底;
  • 如果问题都是“怎么理解”“有什么区别”这类语义开放性表达,向量路要承担主要召回;
  • 如果知识库里大量是制度条款、操作手册、FAQ,两条路都不能偏废。

RRF 的好处在于,即使你不做权重调整,它也能把两路结果稳定融合。但应用层可以保留一个策略开关:某些业务场景强制要求关键词路必须命中,某些场景只走向量路。我们的做法是给每个知识库配置一个“检索模式”字段,默认 hybrid,特殊场景切向量或关键词单路。这个设计在后面做评测时会非常有用。

3. 在 PostgreSQL 里动手实现:表结构、两路召回与 RRF 融合

3.1 基础设施准备:在已有 PG 实例上做加法

不需要单独建一套环境,直接在业务 PostgreSQL 实例里操作即可。我们用的是 PostgreSQL 16 的实例,安装 pgvector 扩展后执行CREATE EXTENSION vector;就算完成了基础准备。

如果是从零搭建,建议给数据库预留足够的共享内存和maintenance_work_mem。建索引是内存敏感的,maintenance_work_mem至少要设置到 256MB 以上,否则一个几十万的 HNSW 索引可能建得极慢。顺便说一句,shared_preload_libraries里最好加上pg_stat_statements,后面排查慢查询就会方便很多。

3.2 表结构设计:向量、全文索引与业务字段同库共存

核心表结构要同时承载业务字段、全文检索字段和向量字段。下面是经过实际调整后的建表语句:

CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE document_chunks ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, doc_id bigint NOT NULL, chunk_index int NOT NULL, content text NOT NULL, content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('chinese_conf', content)) STORED, embedding vector(768), tenant_id bigint NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (doc_id, chunk_index) );

两个字段值得特别说明。

content_tsv是生成列,写入时自动把content转成全文检索用的 tsvector,省去了应用层维护这个字段的麻烦。要注意的是,这里用的是chinese_conf这个自定义全文检索配置,不是 PostgreSQL 默认配置。如果你暂时没有装中文分词扩展,可以先拿simple配置跑通流程,但生产环境必须换掉,这一点后面单独说。

embedding vector(768)是向量列,维度取决于你用的嵌入模型。常见开源模型大多输出 768 或 1024 维的向量。维度越高,表征能力越强,但索引内存占用和查询延迟都会上升。对于一般企业内部知识库,768 维是一个很好的平衡点。

接着建两个索引:

CREATE INDEX idx_chunks_embedding ON document_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); CREATE INDEX idx_chunks_tsv ON document_chunks USING gin (content_tsv);

HNSW 索引的参数后面会细说,这里先按 m=16、ef_construction=64 起步,这个配置在几十万数据量级别已经能拿到不错的召回率。GIN 索引是 PostgreSQL 全文检索的标准索引,维护成本可控。

3.3 向量路与关键词路的召回实现

向量路排序用余弦距离操作符<=>。pgvector 里<=>返回的是余弦距离,余弦相似度是1 - distance:

SELECT id, 1 - (embedding <=> $1::vector) AS similarity, content FROM document_chunks WHERE tenant_id = $2 ORDER BY embedding <=> $1::vector LIMIT 100;

这里注意一点:WHERE tenant_id过滤条件和 HNSW 索引的结合并不理想。如果 tenant 数量少、每个租户的数据量大,可以考虑按租户建立分区表或者部分索引;如果租户数量很多且数据分散,更现实的做法是先不加租户过滤跑一个较大的候选集,到应用层再过滤,否则 HNSW 可能最后只给每个租户剩一点候选,成功率和召回率都会下降。这个问题我们上线第二周就踩到了,具体会在最后一节展开。

关键词路用 PostgreSQL 全文检索:

SELECT id, ts_rank_cd(content_tsv, plainto_tsquery('chinese_conf', $1), 32) AS rank FROM document_chunks WHERE content_tsv @@ plainto_tsquery('chinese_conf', $1) ORDER BY rank DESC LIMIT 100;

ts_rank_cd是 PostgreSQL 自己的排序函数,严格说它和 BM25 公式不是同一套东西,但在实际效果上扮演的角色完全相同:对关键词命中做加权打分。用 normalization 参数 32 可以把分值限制到rank/(rank+1)区间,避免高频词带来的极端分数主导排序。

如果你对“严格 BM25”有执念,可以关注 PostgreSQL 生态里的一些索引扩展,它们提供更贴近 BM25 算法的实现。但对大多数 RAG 场景,ts_rank_cd配合良好分词已经足够,而且不增加额外运维负担。

3.4 混合融合:用 RRF 把两个排名合成一个答案

两路召回分别返回 top 100,然后在应用层做 RRF 融合。给一段简化的 Python 伪代码:

def hybrid_search(conn, query_text, query_embedding, top_k=20, rrf_k=60): # 第一路:关键词召回 bm25_sql = """ SELECT id FROM document_chunks WHERE content_tsv @@ plainto_tsquery('chinese_conf', %s) ORDER BY ts_rank_cd(content_tsv, plainto_tsquery('chinese_conf', %s), 32) DESC LIMIT 100 """ # 第二路:向量召回 vec_sql = """ SELECT id FROM document_chunks ORDER BY embedding <=> %s::vector LIMIT 100 """ # 执行两路,各自获得 id -> rank 映射 # 最后计算 rrf_score = sum(1.0 / (rrf_k + rank))

也可以在 SQL 层面完成融合,用 CTE 把两路结果 UNION 后按 RRF 公式聚合:

WITH bm25_res AS ( SELECT id, row_number() OVER (ORDER BY ts_rank_cd(content_tsv, plainto_tsquery('chinese_conf', $1), 32) DESC) AS rnk FROM document_chunks WHERE content_tsv @@ plainto_tsquery('chinese_conf', $1) LIMIT 100 ), vec_res AS ( SELECT id, row_number() OVER (ORDER BY embedding <=> $2::vector) AS rnk FROM document_chunks ORDER BY embedding <=> $2::vector LIMIT 100 ), all_res AS ( SELECT * FROM bm25_res UNION ALL SELECT * FROM vec_res ), rrf AS ( SELECT id, SUM(1.0 / (60 + rnk)) AS rrf_score FROM all_res GROUP BY id ) SELECT c.id, c.doc_id, c.content, r.rrf_score FROM rrf r JOIN document_chunks c ON c.id = r.id ORDER BY r.rrf_score DESC LIMIT 20;

这个单 SQL 版本适合理解概念,生产上我更推荐先在应用层做融合,理由有三个:一是逻辑清晰,方便加日志、加缓存、做策略分支;二是两路查询可以通过两个连接并行执行,避免串行延迟;三是后续切换检索模式(hybrid 切单路)时只改应用代码,不用动 SQL。

融合之后还有一步容易漏:按doc_id去重。一个文档切成多个 chunk,两路召回结果里可能五条内容都来自同一篇文档,这会让 RAG 拿到的上下文过于单一。去重逻辑排在 RRF 得分排序之后,相同的 doc_id 只保留得分最高的 chunk,保证最终返回的上下文来源足够多元。

4. 企业生产绕不开的坑:中文分词、索引参数与查询治理

4.1 中文分词:默认全文检索配置在中文面前失灵

PostgreSQL 默认的全文检索配置主要针对英文。中文如果不装分词扩展,to_tsvector('simple', content)会把一整句中文直接当成一个词项存进去,查询“上云”时完全匹配不到“企业上云安全指南”里的“上云”。这不是调参能解决的,必须给 PostgreSQL 装一个支持中文整词切分的扩展,并向它注册自定义全文检索配置。

选分词扩展时有三个标准:是否支持你当前用的 PostgreSQL 版本、是否支持自定义用户词典、词库是偏向通用领域还是可以覆盖企业专有名词。我们把知识库里出现频率高的产品型号、系统代号、业务术语维护到用户词典里,比如“TRX-900”“等级保护”“数据分类分级”,让分词器按整词切分而不是拆成单字。这一步不做,关键词路的召回质量会非常不稳定,混合检索等于缺了一条腿。

分词问题属于持续维护项,不是上线一次就结束。每次知识库引入新的产品线,都要把新名词同步进词典。我们会在发布流程里加一步“词库同步检查”,确保应用更新和词典更新一起上线。

4.2 向量索引调优:HNSW 参数与资源规划

HNSW 索引有四个参数要关注:m、ef_construction、ef_search 和索引内存占用。

m控制每个节点的最大连接数,越大索引越密集、召回越高,但内存和构建时间也越高。ef_construction控制构建时搜索的候选范围,同样越大越准。默认值通常适合小型数据量,我们实测下来,几十万切片以内用 m=16、ef_construction=64 已经够用;如果对召回要求高,可以提升到 m=32、ef_construction=128,索引体积大约翻一倍。

ef_search是查询时控制搜索广度的参数,意思是每次查询在图里展开多少候选节点。pgvector 没有全局配置,要在会话级别设置:

SET hnsw.ef_search = 100;

这个值影响查询延迟非常明显,40 是偏保守的默认值,100 到 200 会带来显著的召回提升。需要注意:连接池场景下,SET是会话级状态,如果复用了连接一定要在事务内使用SET LOCAL,或者查询后重置,否则容易污染后续请求。我们把SET hnsw.ef_search = 100;放在每个检索请求的显式事务里,配合statement_timeout一起使用。

资源估算方面,768 维向量每行原始数据约 3KB,100 万行向量本身就接近 3GB,HNSW 索引通常还要占用原始数据一倍左右的内存,整体在 6GB 以上。我们生产库 50 万切片,给这个实例分 16GB 内存,运行几个季度没有出现内存压力。如果维度升到 1024,内存压力还会显著上涨,所以选 embedding 模型时不要盲目追求高维。

4.3 查询治理:超时、去重、权限隔离和召回测评

生产环境和 demo 最大的区别在于:你无法保证所有查询都是善意的,也无法保证 HNSW 每次都能在几十毫秒内返回。我们给所有检索请求套上了三层管控。

第一层是超时。混合检索会并发执行两条 SQL,每条都必须设置statement_timeout。建议在事务内设置,例如SET LOCAL statement_timeout = 3000;,3 秒足够覆盖绝大多数慢查询。如果某个时间点系统负载高,宁可让查询超时返回空结果,也不要让烂查询拖垮整个数据库。

第二层是权限隔离。知识切片表里的每一行都必须带tenant_id,应用层强制按租户过滤。为了防住“应用层忘记过滤”这类低级错误,我们直接在表上开了 PostgreSQL 的行级安全策略(RLS),强制数据库层校验租户条件。这不是可选项,是必须项——否则用户的问题可能把其他租户的私有文档拉进上下文,那是严重的合规事故。

第三层是召回评测。混合检索上线前,我们人工整理了一个评测集:50 条真实用户问题,每条标注理想命中的文档 ID。每次调整分词词典、HNSW 的 ef_search、RRF 的 k 值,都拿这个评测集跑一遍,计算 Top 20 命中率。没有这个评测集,一切调参都是手感流,无法回归验证。后来我们还在评测集里加了“必须精确匹配编号”的特殊问题,用它们来守护关键词路的质量底线。

4.4 一个容易被忽略的维护项:索引重建

HNSW 索引不是建完就一劳永逸的。如果知识库频繁做大批量删除和更新,索引内部会产生一定程度的膨胀,查询性能会缓慢下降。我们的应对策略是每季度做一次重建,重建时用CREATE INDEX CONCURRENTLY先建一个带不同名字的索引,成功后删掉旧索引再改回原名,整个过程不阻塞业务读写。这条经验是上线第二个月被慢查询报警逼出来的,现在已经成为例行巡检项目。

混合检索上线之后,我最深的体会是:企业级 RAG 系统真正值钱的部分不是某一组件多先进,而是你愿意花时间把检索效果、成本和运维体验放在一起权衡。pgvector + BM25 这套组合,在数据量不大时撑起整个业务线,是最划算的起点。等哪天真的到了千万级向量、高并发在线检索,再迁移到专用数据库也来得及——届时数据模型、评估集、RAG 应用代码都能平滑过渡,迁移成本远比一开始就背上高价账单低。

最后分享一个小技巧:上线前一定留一套人工标注的召回评测集,几十条真实用户问题即可。每次调整分词词典、ef_search 或 RRF 参数,都拿评测集跑一遍,别靠感觉调参。混合检索的工程细节不少,但只要你把评测闭环建立起来,每一步改动都像在做减法,而不是在碰运气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询