这事得从一笔账单说起。去年年底,项目里的向量数据库服务快到期了,我看了眼续费单,再对照我们过去三个月的实际调用量,心底那杆秤就开始晃了。随后我花了一个周末,把基于 PostgreSQL 的方案搭了出来:pgvector负责稠密向量召回,PostgreSQL 原生全文检索负责 BM25 风格的稀疏召回,再用 RRF 把两路结果融合,组成一套完整的 RAG 混合检索系统。上线到现在跑了几个月,效果完全够用,成本几乎为零。
这篇文章就把整个落地过程摊开讲清楚。内容包括选型判断、混合检索的底层逻辑、PostgreSQL 侧的建表与索引设计、三种融合查询实现方案,以及生产环境里真正会咬人的细节。适合那些不想为了 RAG 单独养一套向量数据库、又希望检索层具备一定工程成熟度的团队参考。
1. 为什么这单续费我不想买了:pgvector 的适用边界
1.1 商业向量数据库和 pgvector 的差距,真不在算法
说实话,商业向量数据库的核心卖点是托管和运维,不是检索算法本身。你掏的钱大部分花在不用自己操心集群、高可用、监控告警、滚动升级这些事上。算法层面,pgvector提供的 HNSW 和 IVFFlat 索引,和市面上主流向量检索方案用的技术栈是同宗同源的,没有本质代差。
但商业方案的钱花得值不值,要看你的规模。如果业务数据只有几百万条文本切片,维度在 768 或者 1024,QPS 也就几十到几百,那这量级在 PostgreSQL 里根本算不上压力。单独为它引入一套新存储,等于同时引入了一套新的运维体系、新的故障域、新的学习成本。
我见过很多团队犯一个错误:PPT 里写着"亿级向量、毫秒级召回",实际业务场景连千万级都没到。需求被夸大了,预算也就跟着花了冤枉钱。先把真实规模算清楚,再决定要不要为"未来可能"买单。
1.2 用一张表算清楚你到底需要多少存储
向量数据占用空间这件事,其实很好算。以 100 万条文档切片、每条一个 768 维的 float4 向量为例:
1000000 × 768 × 4 bytes = 3,072,000,000 bytes ≈ 2.86 GB再加上 HNSW 索引的开销(通常为原始向量的 1.1~1.5 倍),500 万条文档的向量加索引也不到 20GB。这个体量放在 PostgreSQL 里完全属于常规负载,一台普通机器就能扛住。而且业务数据本来就有元数据、正文、标签、状态等关系型字段,用 PostgreSQL 一张表全藏进去,做过滤、分页、联表查询都比独立的向量数据库方便太多。
1.3 什么场景真的需要独立向量数据库
pgvector不是万能的,所以我也把"必须上独立向量库"的清单列出来,避免从一个极端走到另一个极端:
| 判断维度 | 适合 pgvector 的场景 | 建议独立向量库的场景 |
|---|---|---|
| 数据规模 | 千万级以内 | 亿级以上,且持续高速增长 |
| 并发 | 数百 QPS 级别 | 单集群数千上万 QPS |
| 运维人力 | 已有 PostgreSQL DBA 经验 | 完全不想碰数据库内部细节 |
| 多租户隔离 | 可通过行级权限和分区实现 | 需要物理隔离、独立资源池 |
| 特殊能力 | 标准 ANN 检索足够 | 需要自定义量化策略、布隆过滤、原生多模态索引 |
| 成本敏感 | 完全白嫖现有 PG 实例 | 预算充足且业务增长确定性强 |
结论很直接:团队规模不大、数据量还没到千万级、又不想维护两套存储系统的场景,选择 PostgreSQL +pgvector是性价比最高的路径。这也是我把这套方案定义为"生产级轻量方案"的原因。
2. 混合检索不是炫技:BM25 和向量检索是怎么互补的
2.1 纯向量检索的三类翻车现场
很多团队上来直接只用 embedding 做 RAG,上线后才发现检索质量不稳定。问题不在 embedding 模型,而在语义检索的天生盲区。
第一类是精确匹配场景。用户搜索"订单号 202401156",或者设备型号"XK-1024"。这类字符串的特征是:语义上没有任何歧义,就是精确匹配。但 embedding 模型的分布里,"202401156" 和 "202401155" 这种只有一两位差异的字符串,向量距离几乎没差别,召回结果很容易错乱。
第二类是低频词、专有名词。企业内部经常出现缩写、项目的代号、内部系统的名字。这些词在训练语料里太少,模型根本没有为它们构建出有效的语义空间,向量召回表现得像随机猜测。
第三类是否定表达和细节条件。"查一下本月未开票的合同"这句话,关键信息其实是"未开票"。向量检索很容易抓住"合同""开票"这些强语义词,忽略否定标记,把已开票的合同也召回进来。
这些问题不是调模型能救的。你需要另一条召回通道,用精确的词法匹配把这些"硬条件"捞回来。
2.2 BM25 的数学体面:公式、参数和它的局限
BM25 是传统稀疏检索里的经典算法,本质是给每个词加权求和。它的打分公式长这样:
Score(D, Q) = Σ IDF(qi) × [tf(qi, D) × (k1 + 1)] / [tf(qi, D) + k1 × (1 - b + b × |D| / avgdl)]其中tf(qi, D)是词在文档里的出现频次,IDF(qi)是逆文档频率,|D|是文档长度,avgdl是语料平均文档长度,k1控制词频的影响饱和度(一般取 1.2~2.0),b控制文档长度惩罚强度(通常取 0.75)。
这个公式解决了两个关键问题:一是词频超过一定阈值后收益递减,一个词在文档里出现 10 次和出现 50 次,权重差距远没有想象中大;二是长文档天然有词频优势,所以要用文档长度做惩罚,避免一篇很长的文档靠篇幅赢过真正精准的短文档。
需要说明的是,PostgreSQL 内置的ts_rank/ts_rank_cd是 BM25 风格的加权算法,但并不是严格意义的 BM25 公式。如果项目对检索排序有苛刻要求,建议走自定义打分函数或专用检索扩展。不过在很多实际场景里,ts_rank_cd的表现已经很能打了。
这正好可以作为"混合检索里稀疏通道"的基石:它不认识语义,但对精确词汇敏感,工程师写查询条件时心智负担小,结果可解释性强。
2.3 RRF 融合为什么稳妥:分数不可比,排名可比
有了 BM25 和向量检索两条召回通道,下一个问题是"怎么合并结果"。最直觉的做法是把两路分数加权相加,比如0.3 × vector_score + 0.7 × bm25_score。
这个思路在工程上很容易踩坑。BM25 的分数原始值跨度可能是 0 到几十,向量相似度往往在 0 到 1 之间,两边根本不在同一个量纲。你调参调半天,本质上是在找一个"让两类分数勉强可比"的巧合区间,换个数据集就失效。
RRF(Reciprocal Rank Fusion)的解法很聪明:它不比较分数,比较排名。每个文档在两路结果里各有一个名次,最终得分按名次的倒数叠加:
Score(D) = Σ 1 / (k + rank_i(D))k是个常数,经验上取 60 就好。排名第 1 的文档得分远高于排名第 5 的文档,但第 50 名和第 60 名的差距微乎其微。这就像辩论赛评委打分容易有主观偏差,但按入围排名投票就公平得多。两路召回各自的分数分布再奇怪,只要排名是合理的,RRF 就稳定。
这也是我整个方案里最不担心翻车的部分,因为它的数学性质决定了:一路召回完全失效时,另一路还能兜底。
3. PostgreSQL 侧准备:扩展、表结构、索引和中文分词
3.1 一次性装好 pgvector 与中文分词扩展
整套系统跑在 PostgreSQL 14 上。装扩展很简单:
# 在服务器上安装依赖包后,进入数据库执行: CREATE EXTENSION IF NOT EXISTS vector;向量扩展完成,紧接着解决中文分词问题。PostgreSQL 自带的 parser 对中文基本无效,它会把中文句子按字符切碎,或者把整句话当一个 token。我没有用默认配置,而是装了中文分词扩展,并建立独立的分词配置:
CREATE EXTENSION IF NOT EXISTS zhparser; CREATE TEXT SEARCH CONFIGURATION zh (PARSER = zhparser); ALTER TEXT SEARCH CONFIGURATION zh ADD MAPPING FOR n, v, a, i, e, l WITH simple;n, v, a, i, e, l分别对应名词、动词、形容词、成语、叹词和连词。这样配置之后,中文文本会被切分成有意义的词汇单元,后面的全文检索才有用武之地。别忘了检查分词效果:
SELECT to_tsvector('zh', '企业生产环境混合检索系统');如果返回的是分好词的集合,配置就成功了。
3.2 表结构:一张表同时喂饱两路检索
我的核心表设计只有一个表,把业务字段、全文检索字段、向量字段全部放在一起:
CREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, chunk_text TEXT NOT NULL, biz_tag TEXT, tsvec TSVECTOR GENERATED ALWAYS AS (to_tsvector('zh', chunk_text)) STORED, embedding VECTOR(1024), created_at TIMESTAMPTZ NOT NULL DEFAULT now() );这里的tsvec是生成列,写入chunk_text时自动计算分词向量,不需要业务代码手动维护,减少了漏更新的风险。embedding字段不自动生成,需要异步调用模型计算后回填,在写入链路里处理。
索引同样一表双雕:
CREATE INDEX idx_doc_tsvec ON documents USING GIN (tsvec); CREATE INDEX idx_doc_embedding ON documents USING HNSW (embedding vector_cosine_ops);不把向量和文本拆成两张表,是为了查询时避免无意义的跨表 JOIN。很多生产事故都是 join 引爆的,单表上加两个索引,逻辑简单,维护也简单。
3.3 中文分词的隐形门槛
中文分词的坑,比想象中多。第一个坑是标点符号。初始配置下,"RAG 系统"这种包含英文和中文混合的文本,分词结果可能把RAG切成一个 token,把"系统"切成另一个 token,看起来正常。但如果文本里出现"生产环境——混合检索",全角破折号会把词切断,产生意外的空 token。
解决办法是在数据库层把标点忽略:
ALTER DATABASE mydb SET zhparser.punctuation_ignore = true;第二个坑是分词粒度。业务文档里的专业术语如"财务共享中心"可能被切成了"财务"、"共享"、"中心"三个词,检索时用户搜"财务共享中心"反而匹配不上。这种情况下有两种处理方式:往词典加自定义词,或者建立同义词表。后者更适合持续变化的企业语料。
我在生产里用的方式是:给分词配置挂一张同义词词典,把高频业务术语统一映射到标准词形。这比改 parser 源码要稳妥得多。
3.4 HNSW 还是 IVFFlat:索引选择的完整逻辑
pgvector给两个主流索引方案:HNSW 和 IVFFlat。两者特性差异非常明显:
| 维度 | HNSW | IVFFlat |
|---|---|---|
| 构建速度 | 慢 | 快 |
| 查询延迟 | 低 | 较高 |
| 召回精度 | 高 | 受 lists 参数影响 |
| 增量更新 | 友好 | 很差,需要定期重建 |
| 内存占用 | 高 | 低 |
我的建议是:除非你的数据是"一次性导入、很少更新、对内存极敏感"的批量分析场景,否则默认选 HNSW。特别是面向生产环境,文档会持续增删改,HNSW 的增量更新体验比 IVFFlat 好一个数量级。
索引参数方面,HNSW 的m决定每个节点的最大连接数,默认 16;ef_construction决定建索引时的动态候选集大小,默认 64。如果对召回率要求高,可以调大到 100 或 200,代价是建索引时间和内存上升。查询时的ef_search则通过查询条件动态传递,后面第 4 节会讲到具体做法。
另外,如果 embedding 模型输出的向量没有做过归一化,索引操作符要用vector_cosine_ops;如果推理服务那边做了归一化,用vector_l2_ops也一样等效。
4. 混合查询的三条实现路径与实测数据
4.1 方案一:单条 SQL 完成双路召回 + RRF 融合
最省事、事务一致性和性能都够用的方式,是把 RRF 融合直接写进 SQL:
WITH vector_hits AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :query_embedding) AS rank FROM documents ORDER BY embedding <=> :query_embedding LIMIT 50 ), bm25_hits AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsvec, query) DESC) AS rank FROM documents, to_tsquery('zh', :query_text) AS query WHERE tsvec @@ query ORDER BY ts_rank_cd(tsvec, query) DESC LIMIT 50 ) SELECT id, COALESCE(1.0 / (60 + vector_hits.rank), 0) + COALESCE(1.0 / (60 + bm25_hits.rank), 0) AS score FROM vector_hits FULL OUTER JOIN bm25_hits USING (id) ORDER BY score DESC LIMIT 20;这个方案有几个细节值得说。ROW_NUMBER()把原始分数直接转成排名,后续 RRF 只依赖排名,天然规避了两路分数量纲问题。FULL OUTER JOIN保证只有一路召回的文档也能参与融合,COALESCE把缺失那路的贡献按 0 处理。LIMIT 50是召回窗口,窗口太小可能漏掉本该融合进来的文档,太大会拖慢后续重排。
实际使用中,单条 SQL 的执行时间在 10 万级文档规模下大约是 30~60ms,这在绝大多数 RAG 场景里完全够用。
4.2 方案二:应用层双路召回 + 融合器
SQL 方案虽然干净,但不够灵活。遇到需要按用户标签过滤、对结果做个性化重排、或者接业务规则做 A/B 测试的时候,我建议把双路召回放到应用层,融合逻辑单独写一个模块:
def hybrid_search(query_text: str, query_embedding: list, top_n: int = 20): # 查向量库(伪代码,具体取决于你的客户端库) vector_results = vector_search(query_embedding, limit=50) # 查 PG 全文索引 bm25_results = bm25_search(query_text, limit=50) # RRF 融合 fused = {} for rank, doc_id in enumerate(vector_results, start=1): fused[doc_id] = fused.get(doc_id, 0) + 1 / (60 + rank) for rank, doc_id in enumerate(bm25_results, start=1): fused[doc_id] = fused.get(doc_id, 0) + 1 / (60 + rank) return sorted(fused.items(), key=lambda x: x[1], reverse=True)[:top_n]这种方式代码量只多了 20 行,但换来了一个巨大的好处:你可以在融合前后插入任意业务逻辑,比如过滤掉某一类产品、根据用户地域加权、或者提前排除已删除文档。对于业务规则复杂的企业场景,我推荐这一条路。
4.3 方案三的取舍:第三方扩展与自定义 BM25
严谨地说,PostgreSQL 的ts_rank_cd和标准 BM25 的排序公式存在差异。如果团队对排序质量有极客般的追求,有两条路可以走:一是自己用 PL/pgSQL 实现标准 BM25,把文档总数、平均文档长度、词频等统计量定期物化到一张统计表;二是引入实现了完整 BM25 算法的第三方检索扩展。
我的态度是"够用就好"。为了排序上几个百分点的收益,引入一个需要单独维护的扩展,或者维护一堆统计物化逻辑,性价比往往不值。先用ts_rank_cd把系统跑起来,评测集里发现明显的排序缺陷,再针对性升级,这条路最稳。
4.4 实测:从 62% 到 71% 的准确率提升是怎么来的
我把这个混合检索方案放到一个内部知识库评测集上跑过一轮测试。评测集是 50 条来自真实用户的问题,每条有对应的标准答案文档,评价指标是 Acc@5(检索 Top5 内包含标准答案的比例)。
| 检索方案 | Acc@5 |
|---|---|
| 纯向量检索 | 62% |
| 纯 BM25 风格全文检索 | 48% |
| 向量 + RRF + BM25 | 71% |
有意思的是,纯向量检索的命中率高于纯 BM25,但混合之后比单路都高出一截,说明两路召回的失误集合大部分不重叠,RRF 把互补性真正吃到了。检索延迟方面,HNSW 查询 10 万级文档约 15ms,GIN 全文检索约 5ms,融合排序后整体在 40ms 左右,没有让人体感不适的点。
调参上,k=60和召回窗口LIMIT 50不是拍脑袋定的,而是做了两轮网格搜索之后稳定的结果。实际生产里,这俩参数我觉得最值得优先调整。
5. 生产环境最容易被坑的六个细节
5.1 索引坏了和向量没更新:写入链路要设计好
最典型的故障是:业务写了新文档,但 embedding 还在异步计算队列里,结果向量查询迟迟搜不到新内容。我的方案是给表加一个embedding_status字段,配合触发器或应用层事件标记"待计算",后台 worker 拉取待处理数据、调模型、回填。任何一步失败,文档不会进入半可用状态,查询侧可以统一过滤。
5.2 embedding 是否归一化,直接影响索引算子选择
如果你用的 embedding 模型直接返回 L2 归一化向量,那余弦距离就等价于 L2 距离;反过来,如果向量没有归一化,用余弦距离和用 L2 距离的结果会差很多。我踩过一次坑:模型文档里没说归一化,我用vector_cosine_ops建了索引,结果某次模型升级后向量分布变了,召回质量明显掉。后来在推理服务里显式做了一次 L2 归一化,固定在代码里,再没出过这种事。
5.3 共享内存和排序内存:别让 PG 默认值拖后腿
混合查询涉及 GIN 索引扫描、HNSW 搜索、排序聚合,比较吃内存。我在 16GB 内存的机器上做了这些调整:
| 参数 | 设置值 | 说明 |
|---|---|---|
| shared_buffers | 4GB | PG 共享缓存,通常设为内存的 25% |
| work_mem | 64MB | 排序和哈希操作使用,调太高并发时会撑爆内存 |
| maintenance_work_mem | 2GB | 建索引和 VACUUM 时使用 |
| effective_cache_size | 12GB | 告诉优化器系统剩余缓存有多少 |
work_mem尤其要注意:它不是全局参数,每个排序操作都可能占用一份。设置过高会让并发查询时内存爆掉。64MB 是我在 16GB 机器上的折中,具体要看并发数量。
5.4 备份中的向量数据:pg_dump 会拖垮你
pg_dump对普通文本表还好,但对向量列会序列化成文本形式的数组,维数高的时候备份文件会膨胀很多,导入导出也慢。我在生产环境改用持续的 WAL 归档做 PITR,日常备份走pg_basebackup。对小团队来说,这样比定期跑pg_dump更稳,恢复点粒度也更细。
5.5 评测集驱动调参,别靠感觉
每次有人问"RRF 的 k 取多少",我都很紧张。没有评测集,所有参数推荐都是玄学。我建了一个最小评测集,50 个问题、每个问题标注 1~3 个标准文档。每次调整索引参数、k值、召回窗口,先跑一轮评测,对比 Acc@5 变化,再决定上不上线。这样至少保证每次改动都有数据支撑。
5.6 观察什么指标比观察什么面板更重要
生产环境我重点盯四个指标:
- HNSW 索引膨胀率:文档频繁更新后索引会变大,定期 REINDEX。
- 检索 P95 延迟:混合查询的瓶颈往往在排序和网络,不在索引。
- RRF 两路召回的各自命中贡献:哪个方向长期贡献极低,说明对应通道坏了。
- 死元组数量:高频更新下 GIN 索引膨胀很隐蔽,定期 VACUUM。
这些指标看熟了,系统哪里要调心里就有数了。
最后再分享一个实际体会。这套系统上线后,我最大的收获不是省了向量数据库的钱,而是检索层的可排查性大幅提升。BM25 那一路召回,任何一条结果都能解释清楚"因为文档里出现了某个词所以被召回了",向量那一路解释起来费劲,但长尾语义靠它兜底。两路结合,既有的放矢,又覆盖盲区,这才是混合检索真正打动我的地方。
如果后续你也想复刻这套方案,建议先把评测集建好,再多花一点时间选合适的中文分词配置。这两步做好了,后面的路会顺很多。