朋友们,我上个月刚帮一家做企业内部知识库的团队,把一套“计划采购专用向量数据库”的方案给拦下来了。他们当时的处境很典型:业务方催着上线 RAG 问答,技术负责人看了几个商业化向量数据库的报价,年费几十万起,还没算上额外一套系统带来的运维成本。我说先别急,你现在的 PostgreSQL 里其实已经躺着两个杀器——原生扩展 pgvector 和全文检索,把它们组合起来做混合检索,至少在千万级文档以内的企业场景,效果不会比专用向量数据库差太多,省下来的钱够服务器跑好几年。
这篇文章就完整聊聊这个思路:怎么基于 pgvector 做向量召回,怎么用 BM25 做关键词召回,两者怎么配合排序融合,以及我在这类项目里踩过的坑和沉淀的参数经验。适合正在评估 RAG 检索方案的技术负责人、后端开发,以及想绕过重基础设施搭建检索系统的朋友。
1. 先想清楚:企业 RAG 检索到底需要什么
1.1 场景拆解:知识库检索的技术要求
企业知识库 RAG 和搜索引擎、通用对话机器人不一样。它的文档来源通常很杂:产品手册、客服话术、技术文档、规章制度、历史工单,甚至还有扫描件 OCR 出来的文本。文档数量乍看很多,但真正拆成检索单元(chunk)之后,绝大多数企业级项目都在几十万到几百万这个量级,上千万的属于极少数。
这个量级下,真正的技术难点并不在“存不下”,而在三个地方:第一,检索精度要求高,用户问“报销流程里发票抬头写错了怎么办”,系统必须把带有“发票抬头”“报销单作废”这些关键细节的段落找出来,而不是只给一个“报销流程”的大类;第二,企业知识里有大量专有名词、型号、编号、人名、法条,比如“A3-42 型伺服电机”“合同编号 HT-2024-0081”,这类信息语义上很难用“模糊”的向量去表达,必须靠精确的关键词匹配;第三,数据权限和实时性要命,一个文档刚更新,用户就得能检索到新内容,不能像搜索引擎那样等半天才收录。
把这三个要求摆在一起就会明白:单一的纯向量检索方案,在专有名词和精确匹配场景下很容易翻车;而单一的关键词检索,对“语义相近但用词不同”这类问题又无能为力。所以生产级 RAG 系统,基本都是“向量召回 + 关键词召回”两条腿走路,谁也别想取代谁。
1.2 方案选型:为什么没必要上独立向量数据库
先摆一个我的观点:除非你管理的向量规模在亿级以上,或者对检索时延有单查询 10ms 以内的硬性要求,否则在企业内部知识库这个场景里,上一套独立的向量数据库,大概率是给自己找麻烦。
麻烦主要来自三块。第一块是运维复杂度,独立的向量数据库是一个完整的状态ful服务,要单独部署、监控、扩缩容、处理数据迁移,出了问题还得排查不太熟悉的日志;第二块是数据一致性,企业文档的数据源和最终展示通常还是在传统关系库里,向量库里存的 chunk 数据需要跟业务数据保持同步更新,一旦两边数据对不上,用户检索出来的东西就会和真实业务情况脱节;第三块是成本,不仅指采购费用,还包括团队学习成本,新人接手这套系统需要重新理解一套 API 和存储模型。
用 PostgreSQL + pgvector 的逻辑很简单:向量数据根本不“出库”,它和 chunk 原文本、业务标签、权限字段住在同一张表里。插入一条文档切片,原文本、向量、创建时间、所属部门、权限级别,一次写入全部落盘;按部门过滤权限,写一个普通的 WHERE 条件就行;做事务回滚,完全遵循原有数据库的 ACID 规则。对以“稳定可用”为第一目标的企业项目来说,少一套系统就是少一类故障。
1.3 BM25 在整套系统里的定位
BM25 不用神话它,它本质上是一个“讲究一点的 TF-IDF”。它会把一个查询词条拆分成多个关键词,然后根据这些词在文档里出现的频率、词在全部文档里的稀有程度、文档本身的长度,计算出一个相关度得分。跟朴素 TF-IDF 比,BM25 多做了两件事:一是对词频做非线性饱和处理,一个词出现 20 次并不比出现 10 次好上一倍;二是引入文档长度归一化,避免长文档因为“词多”而天然占便宜。
在 RAG 混合检索里,BM25 最擅长的是兜住“精确匹配”的底线。我做过一个测试:在某产品知识库中随机抽了 200 个真实用户问题,纯向量检索在前 10 条结果里面找到正确答案的比例大概是 68%,单独跑 BM25 大概是 55%,两者混合之后,这个数字直接跳到 87%。跳升的核心来源就是那些包含型号、工单号、错误代码的查询——向量搞不定,关键词一打一个准。
2. 系统架构与核心设计
2.1 整体结构:一套库、两条召回链路、一次融合
整个系统的架构图,在我脑子里永远是三横一竖的结构。最底层是 PostgreSQL 数据库实例,它既当 OLTP 库存业务数据,也当向量库存 embedding,还当全文检索引擎跑 BM25;中间层是三种索引,GIN 索引用于全文检索,HNSW 或 IVFFlat 索引用于向量相似度检索,普通 B-tree 索引用于权限过滤和业务筛选;最上层是一层薄的检索服务,负责把用户的自然语言查询拆成两个并行的召回动作,再把结果做归一化和排序融合,最后交给大模型生成回答。
这样做有一个隐形好处:数据流只有一个入口和一个出口。文档进入系统后,在应用层做切片,生成 embedding,然后丢进数据库,后续所有的更新、删除、权限过滤都在同一套事务里完成。不需要写“先更新数据库、再调用向量数据库 API、失败还要考虑补偿”的跨系统代码。
2.2 数据表设计:让向量和文本住同一层
表结构这块,我习惯这样设计,大家可以直接抄作业:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE doc_chunks ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), doc_id uuid NOT NULL, chunk_index int NOT NULL, chunk_text text NOT NULL, chunk_tsv tsvector, embedding vector(1536), dept_id int, owner_role varchar(32), created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); CREATE INDEX idx_chunks_tsv ON doc_chunks USING GIN (chunk_tsv); CREATE INDEX idx_chunks_embedding ON doc_chunks USING hnsw (embedding vector_cosine_ops); CREATE INDEX idx_chunks_doc_id ON doc_chunks (doc_id);几个字段的用途要解释一下。chunk_tsv是全文检索的基础,搜索时数据库会通过 GIN 索引快速定位包含关键词的 chunk,然后用 ts_rank 函数计算 BM25 风格的相关性得分;embedding字段存向量,用 HNSW 索引做近似最近邻检索,vector_cosine_ops表示用余弦相似度来度量;dept_id和owner_role是给权限过滤预留的,很多企业场景要求不同部门看到的知识范围不一样,直接在 SQL 里带个 WHERE 条件就能实现,非常方便。
不是每个项目都需要chunk_tsv和embedding同时存在,如果你的业务明显偏向短文本匹配,可以暂时不建向量索引只跑关键词检索;如果偏向语义搜索,也可以先只建向量部分。但做混合检索落地方案时,我建议两个都建好,并用同一份数据录入脚本去填充,留好后路。
2.3 混合检索的召回与排序策略:RRF 与加权归一化
两条召回链路各有各的“分数”,但它们俩的分数尺度完全不同:向量余弦相似度是 0 到 1 之间,BM25 的得分理论上可以到几十甚至上百。如果不做处理直接相加,等于把排序权完全交给了 BM25,向量召回的结果再准也翻不了盘。
目前业界最常用的两种融合方式:一种是 RRF(Reciprocal Rank Fusion),另一种是带权重的分数归一化。RRF 的思路很简单优雅:每个召回源各自返回一个有序列表,然后按名次计算融合分:每个文档的得分 = Σ 1 / (k + rank),其中 k 是个经验常数,通常取 60。RRF 不看原始分数,只看名次,所以完全不受分数量纲影响,而且效果非常稳,我个人最喜欢用它。
另一种是归一化加权。把 BM25 原始分做 min-max 归一化到 0 到 1,向量相似度本来就是 0 到 1,然后给两个通道各设一个权重 alpha 和 1-alpha,最后计算加权和。这种方法比 RRF 稍微敏感一些,因为 min-max 归一化容易受到离群值干扰,但如果业务上希望突出语义检索或者突出关键词检索,可以通过调节 alpha 实现。
在实际系统里,我通常两个都实现一遍,默认跑 RRF,参数配置页保留一个“alpha 模式”的开关,方便做 A/B 测试和参数调优。
3. 从零搭建:pgvector + BM25 混合检索实操
3.1 环境配置与 pgvector 安装
pgvector 的安装非常顺,大多数云厂商的托管 PostgreSQL 现在已经直接内置了,没有的话就自己装扩展。以 Ubuntu 环境为例,如果 PostgreSQL 是用 APT 装的:
# 安装 postgresql-server-dev 并编译安装 pgvector sudo apt install postgresql-server-dev-16 git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git cd pgvector make && sudo make install装完以后,登录数据库执行:
CREATE EXTENSION IF NOT EXISTS vector; SELECT extversion FROM pg_extension WHERE extname = 'vector';看到版本号输出就说明环境没问题。补充一点:如果你用的是 Docker 里的 PostgreSQL,推荐直接换带 pgvector 的官方镜像,比如pgvector/pgvector:pg16,省去编译依赖的麻烦。这里说的镜像主要是为了配合云原生部署,后续操作步骤完全不变。
3.2 建好全文检索:从 tsvector 到自定义分词
PostgreSQL 内置的全文检索是依赖 tsvector 的类型。建立全文索引得先把 chunk_text 转成 tsvector,这一步需要指定“语言配置”(parser),英语默认用english,中文则要谨慎处理。
PostgreSQL 自带的simple或者english分词器对中文非常不友好,会把一整句连续的中文当成一个 token,导致检索效果约等于没有。举一个我踩过的例子:原文“请将发票抬头修改为报销人姓名”,用默认分词器会被切成若干英文式的 token,效果惨不忍睹。
解决方案通常有两种。一种是用外部分词器(比如 jieba 或 HanLP)在应用层做好分词,然后把分词后的词序列用空格连接,再填入tsvector字段,相当于“预处理分词”;另一种是编译安装中文分词扩展,比如zhparser、pg_jieba,让数据库自己具备中文分词能力。对生产系统来说,我倾向用第一种,因为应用层可控性更强,可以直接复用已有的分词流水线,也能精确控制“哪些词需要切成重点词”。
举个例子,应用层的切分结果可能是:
请/将/发票/抬头/修改/为/报销人/姓名把它写入chunk_tsv字段:
INSERT INTO doc_chunks (doc_id, chunk_index, chunk_text, chunk_tsv, embedding) VALUES ( 'doc_123', 0, '请将发票抬头修改为报销人姓名', to_tsvector('simple', '请 将 发票 抬头 修改 为 报销人 姓名'), '[0.001, ...]' );你会发现我用的语言配置是simple,因为应用层已经把空格分离的词喂进来了,simple会把空格分隔的片段逐个当成 token,不会再做多余处理。这种做法在中文场景下实测有效。
3.3 HNSW 向量索引:参数选择与底层逻辑
向量索引方面,PostgreSQL 里最常用的是 IVFFlat 和 HNSW。IVFFlat 的原理是把向量聚类分桶,查询时只搜索最近几个桶,优点是构建快、内存占用低,缺点是需要提前训练聚类中心(数据量大时聚类质量不稳);HNSW 的原理是跳表式的多层图结构,查询时从顶层快速逼近底层,准确率高、不需要预训练,但构建更慢、内存占用更大。
我现在项目上一律默认用 HNSW,除非数据量超过几千万且内存特别吃紧才退回 IVFFlat。建索引时有两个关键参数:
m:每个节点在图里的最大连接数,默认 16。调大到 32 会提高召回精度,但会显著增加索引构建时间和内存占用;ef_construction:构建索引时动态候选列表大小,默认 64。调大到 128 通常能让索引质量更好,召回更准,但构建时间几乎翻倍。
还有一个查询参数ef_search,不是建索引时设置的,是查询时动态设置的,它控制在图上搜索时的候选规模,越大召回越准,但查询越慢。实战里我建议ef_search从 40 起步,如果感觉召回不行就放大到 80,再往上对精度的边际收益就很小了。
建索引的实际 SQL:
CREATE INDEX ON doc_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);我自己的测试经验是:100 万条 1536 维向量,在普通的 8 核 16G 机器上,m=16、ef_construction=64 的索引构建时间大约在 20 到 40 分钟之间。这在初始化导入场景下完全可以接受,但注意别在业务高峰期做全量建索引。
3.4 混合查询 SQL:一个查询同时走两条路
把两部分索引势力拧在一起,不需要写复杂的应用层代码,一条 SQL 就能完成。先做关键词召回:
WITH bm25_hits AS ( SELECT id, chunk_text, ts_rank_cd(chunk_tsv, query) AS bm25_score FROM doc_chunks, plainto_tsquery('simple', '发票 抬头') AS query WHERE query @@ chunk_tsv ORDER BY bm25_score DESC LIMIT 30 ), vector_hits AS ( SELECT id, chunk_text, 1 - (embedding <=> '[0.001, ...]'::vector) AS vector_score FROM doc_chunks ORDER BY embedding <=> '[0.001, ...]'::vector LIMIT 30 ), fused AS ( SELECT id, chunk_text, -- 这里给一个简易的 RRF 融合 1.0 / (60 + row_number() OVER (ORDER BY bm25_score DESC)) AS score FROM bm25_hits UNION ALL SELECT id, chunk_text, 1.0 / (60 + row_number() OVER (ORDER BY vector_score DESC)) AS score FROM vector_hits ) SELECT id, chunk_text, SUM(score) AS final_score FROM fused GROUP BY id, chunk_text ORDER BY final_score DESC LIMIT 8;这个 SQL 有几个点值得展开。<=>是 pgvector 里计算余弦距离的运算符,风格很直观:左边是向量列,右边是查询向量。1 - 距离可以粗略当成相似度分数。plainto_tsquery会把用户输入“发票抬头”自动转成两个词的匹配条件,与chunk_tsv做匹配。RRF 那部分我直接用了row_number()来取每条通道的排名,最终把所有通道的1/(60+rank)相加,就是融合分。
如果业务上更想要可调节权重的模式,可以在外层把SUM(score)替换成SUM(alpha * vector_score_normalized + (1-alpha) * bm25_score_normalized),但需要先做归一化子查询,SQL 会稍微长一些,逻辑反而不复杂。
3.5 参数调优:alpha 怎么调、阈值怎么定
调参的核心原则只有一个:用真实语料测,不要拍脑袋。我在项目里通常这样做:
- 挑一组有代表性的业务查询和标注好的正确答案,组成评测集,至少 50 条以上;
- 让系统分别以 alpha=0(纯 BM25)、alpha=0.5、alpha=1(纯向量)三种配置跑一遍,记录 top-k 命中率;
- 看看 fail 的 case 分布在哪个通道,再朝相反方向调。
经验上,企业内部知识库 alpha 落在 0.4 到 0.6 之间是比较稳的区间。如果用户查询里长尾的问题多、口语化强,可以调到 0.6 以上;如果查询普遍包含明确的型号、编号、产品名,alpha 往 0.4 以下调效果更好。注意,beta 和 k 参数如果用的是 PostgreSQL 内置 ts_rank_cd,它内部已经实现了类似 BM25 的公式,应用层一般不用再单独设置 k1、b 这些参数;如果用 pg_bm25 扩展,k1 和 b 才是暴露出来的超参,k1 建议保留在 1.2 附近,b 建议 0.75,这是业界 benchmark 出来的推荐起点。
关于相似度阈值,我建议不要用“绝对阈值”当硬过滤条件,而是把 top-k 截断作为主手段,阈值只做最后一道安全网。原因是 embedding 模型在不同的文本分布上,余弦相似度基准差异很大,某套模型里 0.75 算高,另一套可能 0.65 就已经很高了。如果一定要设,可以参考落在检索结果集前 30 条的最低分,把这个值乘以 0.9 当动态阈值,会稳定很多。
3.6 接进 RAG 流程:从检索结果到大模型回答
混合检索跑完,后端拿到的就是一组按融合分排好序的 chunk。接下来进入 RAG 生成阶段,一块交给大模型。我的建议是:
- 不要把 top-k 一次性全部塞进 prompt,按融合分从高到低,先放前 3 条,最多放到 5 条,超出 5 条既浪费 token 又会引入噪声;
- 每条文本前加一个元信息前缀,比如《文档名》第几节、更新时间,方便大模型在回答里引用来源;
- 在 prompt 里加一句硬约束:“如果检索内容与问题无关,请回答不知道。”这句约束能让大模型的幻觉率下降不少。
一个实际用的 prompt 模板大概是这样的:
请基于下面给出的知识片段,回答用户的问题。每个片段以 [来源] 开头。 [来源] 产品手册-章节3 片段:将发票抬头修改为报销人姓名,具体操作路径为…… [来源] 财务制度-2024版 片段:报销人应确保发票抬头与报销主体一致…… 用户问题:发票抬头写错了怎么办?这个流程跑起来以后,还要做一件事:记录每轮问答里用户实际点击了哪条来源、是否对回答点了赞,把这些反馈回填到检索评测集里。RAG 系统在真实环境里几乎一定会出现“检索没问题、大模型答偏了”和“大模型没问题、检索召回错了”两种错位,没有反馈数据,你分不清病根在哪。
4. 性能、稳定性和成本的全方位评估
4.1 查询性能的实测感受
不少人对“PostgreSQL 跑向量到底行不行”有疑虑,拿我们线上某套系统为例做个参考。数据规模:80 万 chunk,768 维向量(我们用了一个轻量 embedding 模型),正文平均 400 字;机器配置:8 核 16G,SSD,PostgreSQL 16 + pgvector 0.6。
混合检索单条 SQL 的 p99 时延在 180ms 到 260ms 之间,其中向量索引扫描占了大部分耗时。如果只跑关键词检索,时延在 30ms 以内;只跑向量检索,时延在 120ms 左右。对绝大多数 RAG 场景来说,这个延迟完全够用,因为最终大模型生成回答的时间动辄好几秒,检索的几百毫秒根本感知不到。
如果哪天数据量翻倍到千万级,HNSW 索引构建和查询时延会明显上升,但也不会立刻崩掉。我查过一些衡量基准的数据:千万级、768 维向量的 HNSW 查询,在合理的硬件条件下通常也能控制在 300ms 上下,这个性能天花板对“企业知识库”这个体量来说,离撞到还早。
4.2 备份、监控与数据一致性
用 PostgreSQL 做向量库带来的一个隐性好处是,备份体系完全复用现有能力。向量数据、业务数据在同一个实例里,做物理备份、PITR 时间点恢复、主从流复制,都不需要额外开发任何东西。我之前维护过一套独立的向量检索集群,备份要单独写一套脚本,还要考虑“备份向量库的时机”和“备份业务库的时机”怎么对齐,每次演练都像打仗。
监控方面更简单,Prometheus 的 postgres_exporter 可以直接监控索引大小、查询延迟、活跃连接数。另外给向量表单独建一个监控查询,每天扫一遍索引膨胀率。pgvector 没有像普通 B-tree 那样的自动回收机制,频繁更新和删除可能导致索引膨胀,最好是定期对向量表执行VACUUM,维持索引稳定。
4.3 成本账本:一年能省多少
算一笔直观的账。一套 80 万文档切片规模的企业系统,如果采购托管版专用向量数据库,基础配置月费起步一般在数千到上万,加上存储费用和可能的出流量费,一年下来十万元级非常正常。用现有 PostgreSQL 实例扩展,额外成本是多少?答案是零:即使需要给 PostgreSQL 升配(内存和磁盘各加一些),费用也远低于新引入一套系统。
这里的账不只是采购费,还有人力账。少维护一套系统,省掉日志采集、告警配置、版本升级、故障排查这些隐性工作量,一年能省出好几个“人月”。做技术方案时,这比纯硬件成本更有说服力,也更容易让决策者接受。
5. 踩坑实录与常见问题速查
5.1 几个教训深刻的坑
坑一:先做向量检索,不做关键词召回。最初上线的一个版本只用了向量检索,产品反馈说“用户问 HT-100 型号,回答里经常给的是 HT-101 的内容”,因为两个型号的语义向量太接近了。后来把 BM25 召回加进去,型号类问题命中率一下子上来了。这个教训的价值在于:混合检索不只是“锦上添花”,在强符号信息场景里是“根本必需品”。
坑二:中文分词没做,直接拿默认配置建 tsvector。上线后发现关键词检索严重漏召回,比如“报销单”三个字作为一个整体 token,用户查“报销”就匹配不到。后来用外部分词器预处理,把词统一用小写、去除特殊符号,再灌进 tsvector,问题立刻缓解。提醒各位,分词词典需要定期补充企业专属词汇,如产品型号、内部系统名称,否则这些词会被切得稀碎。
坑三:向量维度不统一。有一次重新换了 embedding 模型,新模型的向量是 1024 维,老的表结构是 1536 维,结果所有插入直接报错。生产环境假如有换模型的计划,建议预先设计一张“模型元信息表”,记录每个 chunk 使用的是哪个模型、哪个版本、什么维度,换模型时强制按版本做全量重算,避免新旧数据混用导致检索质量波动。
5.2 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 关键词检索完全查不到中文内容 | 用的是默认 english 分词配置 | 应用层预处理中文分词,或用simple配置配合空格分隔后的文本 |
| 向量检索召回的相关性低 | ef_search 太小 | 调大查询端ef_search,从 40 到 80 实测对比 |
| 混合检索时关键词通道几乎不起作用 | BM25 分数没归一化就被加权 | 改用 RRF,或在加权前做 min-max 归一化 |
| 插入数据变慢、索引构建特别慢 | HNSW 参数设置过重 | 调降 m 到 16,ef_construction 到 64 |
| 删除文档后,检索还能命中旧内容 | 向量索引膨胀,未清理 | 执行VACUUM,必要时重建 HNSW 索引 |
| top-k 里混进大量无关 chunk | chunk 切得太碎太长,或阈值太低 | 调整切片长度,按融合分排序后只取前 3-5 条 |
| 线上更新 embedding 模型后结果很乱 | 新旧向量混用,未区分版本 | 设计模型版本表,切换模型时全量重算向量并重建索引 |
另外有一个容易被忽略的问题:PostgreSQL 的ts_rank_cd计算结果和使用 pg_bm25 扩展得到的 BM25 分数并不一样,前者更偏向快速相关性加权,后者是更“正统”的 BM25。如果业务上要求严格复现 Lucene 风格的排序,建议直接用 pg_bm25 这类扩展;如果只是追求关键词召回这段链路带来的混合增益,用内置 tsvector 就够了,不必追新。两种方案我都跑过,内置方案胜在零额外依赖、升级简单,扩展方案胜在排序可控性强,按团队维护偏好取舍即可。
最后再分享一个小技巧
给所有准备动手做这套系统的读者一个小建议:先别一上来就调参数和优化索引,花半天时间把评测集建好。找业务方要 30 到 50 个真实用户问过的问题,标注出“应该命中哪篇文档的哪个段落”,哪怕标注粗糙一点都行。有了这份评测集,后面所有调优工作才真正有据可依,否则你会陷入“谁嗓门大听谁的”的调参循环。
我在这套方案落地期间最大的体会是:企业检索系统的复杂度,大部分不在技术选型,而在数据质量和评测闭环。pgvector + BM25 这套组合,让整个系统的技术栈极度精简,也让团队把精力从维护基础设施转回打磨检索效果本身。等你们把混合检索跑顺了,再回头看看当初差点买下的专用向量数据库账单,会觉得这步棋走得特别值。