
去年我们团队给 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。直接加权结果会被分数范围大的一方主导。实际推荐用 RRFReciprocal Rank Fusion倒数排名融合它的思路非常朴素根本不看分数绝对值只看排名位置。每个文档在两路召回里的排名分别记为 rank1 和 rank2融合得分是score 1 / (k rank1) 1 / (k rank2)k 常规取 60。很多开源检索引擎在实践里发现 k60 是一个比较稳定的值排名第 1 的得分最高越靠后得分衰减越快。这个算法的优势在于不需要调两个不同量纲分数之间的权重也不用维护复杂的归一化逻辑。举个例子文档 A 在关键词路排名第 2向量路排名第 10RRF 得分约 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 索引的参数后面会细说这里先按 m16、ef_construction64 起步这个配置在几十万数据量级别已经能拿到不错的召回率。GIN 索引是 PostgreSQL 全文检索的标准索引维护成本可控。3.3 向量路与关键词路的召回实现向量路排序用余弦距离操作符。pgvector 里返回的是余弦距离余弦相似度是1 - distanceSELECT 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/(rank1)区间避免高频词带来的极端分数主导排序。如果你对“严格 BM25”有执念可以关注 PostgreSQL 生态里的一些索引扩展它们提供更贴近 BM25 算法的实现。但对大多数 RAG 场景ts_rank_cd配合良好分词已经足够而且不增加额外运维负担。3.4 混合融合用 RRF 把两个排名合成一个答案两路召回分别返回 top 100然后在应用层做 RRF 融合。给一段简化的 Python 伪代码def hybrid_search(conn, query_text, query_embedding, top_k20, rrf_k60): # 第一路关键词召回 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控制构建时搜索的候选范围同样越大越准。默认值通常适合小型数据量我们实测下来几十万切片以内用 m16、ef_construction64 已经够用如果对召回要求高可以提升到 m32、ef_construction128索引体积大约翻一倍。ef_search是查询时控制搜索广度的参数意思是每次查询在图里展开多少候选节点。pgvector 没有全局配置要在会话级别设置SET hnsw.ef_search 100;这个值影响查询延迟非常明显40 是偏保守的默认值100 到 200 会带来显著的召回提升。需要注意连接池场景下SET是会话级状态如果复用了连接一定要在事务内使用SET LOCAL或者查询后重置否则容易污染后续请求。我们把SET hnsw.ef_search 100;放在每个检索请求的显式事务里配合statement_timeout一起使用。资源估算方面768 维向量每行原始数据约 3KB100 万行向量本身就接近 3GBHNSW 索引通常还要占用原始数据一倍左右的内存整体在 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 参数都拿评测集跑一遍别靠感觉调参。混合检索的工程细节不少但只要你把评测闭环建立起来每一步改动都像在做减法而不是在碰运气。