前一阵做企业知识库的 RAG 检索系统,遇到一个特别现实的问题:数据量不算大,几百 GB 的文本和文档,日常并发也就几十 QPS,但商业向量数据库的授权费算下来,一年接近两个后备开发的人力成本。更重要的是,很多内部文档不允许出内网,部署形态五花八门,采购流程也慢。于是我开始琢磨,能不能把这套检索能力拉回 PostgreSQL 生态,用 pgvector 管向量,用 Postgres 自带的全文本检索做近似 BM25 的关键词召回,两边结果再做融合排序,最终搭一个能扛生产流量、又不额外增加硬件负担的轻量级 RAG 混合检索系统。
这段经历如果能给你带来什么参考,我觉得核心不是“PG 能不能替代专用向量库”,而是:在你预算不多、团队已经熟悉关系型数据库、又要求小步快跑上线的情况下,混合检索需要拆成哪些环节,每个环节有哪些坑,以及怎么用最朴素的工具把它们组装起来。
这篇文章不废话,直接把我在实际落地过程中的架构选择、索引参数、SQL 写法、融合排序、排障经验讲清楚。适合正在做 RAG 落地、又不想把整个检索架构押注在昂贵专用数据库上的团队,也适合想进一步理解 pgvector 和 BM25 检索原理的开发者。
1. 项目概述与整体设计思路
1.1 项目出现的背景
这个项目的目标很直白:把公司内部的规章制度、产品文档、历史故障记录、项目复盘说明等非结构化文本,统一接入大模型问答场景。底层需要两个能力,一个是把你输入的 Query 转成向量并做相似度检索,另一个是把你输入的关键字和文档里的关键词做精确匹配。两个能力单独看都有现成方案,难的是合在一个生产系统里,还要控制成本。
一开始我们考察过市面上的商用向量数据库。单看 demo,确实漂亮,百万级向量检索延迟很低,配套的管理界面也齐全。但往生产环境一推就发现问题:
- 数据权限要求项目必须部署在内网,不能直接使用托管云的版本;
- 自建商用版又需要额外一堆机器,维护成本高;
- 最麻烦的是,业务库本身的 SQL 关联查询和权限模型已经用 PG 了,再引入另一套数据系统,两边数据同步就成了新负担。
于是我们把目光转向 pgvector。pgvector 是 PostgreSQL 的开源向量扩展,可以直接在普通表里增加一个 vector 类型的列,支持余弦距离、欧氏距离、内积三种距离函数,也支持 IVFFlat 和 HNSW 两种索引。唯一的担心是“它扛得住吗”,后面实际测下来,百万级以下向量,HNSW 索引配合合理内存,单机完全够用。
1.2 为什么混合检索是 RAG 生产化的必需品
很多团队刚开始做 RAG 时只做向量召回,也就是把文档切块,做 embedding,用户 Query 也做 embedding,然后算个相似度取 TopK。这个方案在开放域闲聊或者语义宽松的场景下效果还行,但在企业知识库这种偏“精确查证”的场景里,它有几个明显弱点。
第一,术语不友好。比如“容灾切换预案”,如果文档里写的是“故障转移流程”,embedding 能勉强关联上,但如果用户想检索的是某个具体的版本号、编号、设备型号,向量相似度基本靠运气。
第二,数字和符号难处理。“A 类设备 12.4 版本”,但文档里写的是“12.4”还是“12.04”,语义上完全一样,向量层可能把它们当成两个不同概念,关键词层却可以精确命中。
第三,关键词有时候是强约束。用户明确查“退款手续费比例”,你召回一堆“退款流程”“手续费计算”的相关文档,反而把真正包含原话的段落挤下去。
BM25 就是解决这一类问题的经典算法。它根据词频和文档长度计算 Query 与文档的相关性,对精确词汇匹配非常敏感。把 BM25 和向量召回结合起来,就是我们常说的混合检索。做法通常是:向量召回一路,关键词召回一路,各自返回一批候选,再做结果合并、去重、重排,最后把重排后的段落拼进 Prompt,交给大模型生成答案。
所以这个项目从一开始就定了一条铁律:不是选向量库还是选 BM25,而是两条腿走路,缺一不可。
2. 检索方案拆解:向量召回、BM25 排序和混合融合策略
2.1 pgvector 的索引选型:IVFFlat 还是 HNSW
pgvector 支持两种索引,刚接触时很容易盲目选择。我先把两者的差异说清楚。
IVFFlat 的思路是把向量空间划分成很多区域,每个区域存一批向量,查询时只在最近的几个区域内做遍历。优点是索引构建快、内存占用相对低,缺点是召回率受查询分布影响,数据量小时效果一般。它有一个关键参数lists,通常建议设置为sqrt(rows),例如 20 万条向量,lists设为 400 左右。
HNSW 的思路是构建一个多层图,每一层的节点根据相似度连接,查询时从高层往低层走,逐层逼近目标。优点是查询精度高、延迟稳定,缺点是构建时内存占用较大,插入数据改建索引也比 IVFFlat 慢。
我做生产选型时直接用了 HNSW,原因是:
- 业务数据会持续增量写入,每次写入都要能立刻被检索到,IVFFlat 对增量场景不够友好,需要周期性重建索引;
- 知识库对召回率的要求高于插入吞吐;
- 单表向量规模控制在百万级以内,HNSW 的内存完全可接受。
HNSW 有两个核心参数:
m:每个节点最多连接的邻居数。调大能提高召回率,但会增大索引体积和搜索耗时,我一般取值 16 到 32。ef_construction:构建索引时的动态列表大小。越大构建越慢,但图质量更高,通常 64 到 128 足够。
查询时还可以临时指定hnsw.ef_search,比如设置为 40 或 100,控制召回质量与延迟的平衡。
2.2 BM25 在 PostgreSQL 里的落地方式
标题里写了 BM25,但这里必须说句老实话:PostgreSQL 原生全文检索用的排序函数是ts_rank/ts_rank_cd,它是基于词频、逆文档频率和文档长度的加权打分,跟经典 BM25 的公式不完全一样,但思路高度一致。传统 BM25 强调“词频不能无限增长,文档短的命中更值钱”,ts_rank 同样有这些倾向。
如果团队一定要使用严格意义上的 BM25 算法,有几个可选路径:
- 在应用层自己读文档词频,实现 BM25 打分。精度最可控,但需要把倒排索引数据同步到 Redis 或内存里,工程量大。
- 使用带 BM25 功能的全文检索扩展。在 PostgreSQL 生态里可以引入更专业的中文分词和全文索引插件。这种方案能继续沿用 SQL 查询,部署也不复杂。
- 对检索质量要求不那么极端时,直接用 tsvector + ts_rank 即可。
我的建议是:先用自己的文档实测 ts_rank 和严格 BM25 的效果差距。以我的经验看,在企业知识库场景下,只要分词器处理得当,两者排序差异很小。真正影响结果的反而是“停用词表”“同义词表”和“分词器选择”。
中文环境有几点必须处理:
- 默认的
simple分词器按整句切分,中文效果很差。 - 推荐使用
zhparser或pg_jieba这类中文分词扩展,并配置to_tsvector('zhparser', content)。 - 提前维护业务“专有词字典”,例如“RAG”“POC”“工单类型”这些词,要确保分词器不要切碎。
建索引时把分词结果做成生成列,既省写入成本,也方便查询:
ALTER TABLE doc_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('zhparser', content)) STORED; CREATE INDEX idx_doc_chunks_tsv ON doc_chunks USING GIN (content_tsv);生成列的意思是,每次插入或更新content时,PostgreSQL 自动重新计算content_tsv,不需要你在业务代码里手动维护,也不容易漏更新。
2.3 两路召回结果怎么合:RRF 融合
两路检索各自返回 Top K,怎么合并成一个最终列表?最简单的方法是分数加权拼接,例如把向量相似度和 BM25 分数归一化后各乘 0.5 相加。但实际跑过之后就发现,两路分数分布差异极大:向量相似度可能是 0.72 到 0.81,BM25 分数可能是 1.2 到 18.6,无论怎么归一化,都很难保证稳定。
更稳的是 RRF(Reciprocal Rank Fusion)。它不直接比较两边的原始分数,而是把每条候选在各自结果列表里的名次作为依据,按下面的公式打分:
score = 1 / (k + rank)其中rank是该文档在当前结果列表中的排名,k是一个平滑常数,常见值是 60。然后计算每个文档在两路排名中的 RRF 得分之和,按和排序。
举个例子:文档 A 在向量召回里排第 2,在 BM25 召回里没出现,那么它的 RRF 分数是1/(60+2) = 0.0161;文档 B 在向量召回里排第 10,在 BM25 召回里排第 3,那么它的 RRF 分数是1/(60+10) + 1/(60+3) = 0.0143 + 0.0159 = 0.0302,B 的总分就会超过 A。这种方法天然弱化了两套模型的量纲差异,不用调分数归一化权重。
实际调参时值得改的是k值。如果希望排得靠前的文档权重更大,把k调小;如果希望给排名靠后但两路都有出现的文档更多机会,把k调大。我跑下来k=60对大多数场景都不错,少量场景调到 30 后搜索结果更聚焦。
2.4 候选集大小和重排策略
混合检索拿回来的不是最终答案,而是“有可能包含答案的候选段落”。所以在融合之后,还需要一轮重排或截断。
我们的流程是:向量召回取 Top 50,BM25 召回取 Top 50,RRF 融合后取 Top 20,再用一个轻量级重排模型,或者简单规则,把段落按与 Query 的相似度重新排序,最终只选 Top 6 到 Top 8 送入 Prompt。
为什么要这样设计?因为大模型输入长度有限,塞太多废话段落反而会稀释注意力。并且 RR F 融合后的前 20 可能仍然存在细微噪声,加一层重排能把真正核心的段落顶上去。重排模型可以先用现成的开源交叉编码器,后期再考虑蒸馏成更小模型。
3. 生产落地实操:建表、写入、查询和调参全记录
3.1 扩展安装与基础表结构
以 PostgreSQL 15 为例,安装 pgvector 只需要两步:
# 进入数据库执行 CREATE EXTENSION vector;确认版本:
SELECT extversion FROM pg_extension WHERE extname = 'vector';基础表我用的是“文档-块”两层结构。文档表存文件元数据,块表存切好的文本和对应的向量:
CREATE TABLE documents ( id bigserial PRIMARY KEY, title text, source_path text, doc_type text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE doc_chunks ( id bigserial PRIMARY KEY, document_id bigint REFERENCES documents(id) ON DELETE CASCADE, chunk_index int, content text, embedding vector(768), content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('zhparser', content)) STORED, created_at timestamptz NOT NULL DEFAULT now() );注意vector(768)的维数必须和你选的 embedding 模型一致。我们用的是某个通用中文向量模型,输出 768 维,如果你换模型,维数也要同步改,不然查询会直接报维度不匹配的错误。
3.2 写入链路:切块、向量化、入库
写入链路没有太多奇技淫巧,关键点是“切块策略”。
很多人把切块理解成“固定字符数”,比如每 512 字一块,互相重叠 128 字。但对企业文档来说,一个重要的优化是按章节和段落边界切。我踩过很多次坑,一旦把一个表格或者一条完整规则从中间切断,向量检索召回的内容会缺上下文,大模型回答时明显能看到价值下降。
我们实际用的切块规则是:
- 先按 Markdown 标题、段落、项目符号等结构拆分;
- 对长度超过 800 字符的块,再按 800 字符切割;
- 相邻块保持 100 字符重叠;
- 每个块记录原始文档 ID 和块序号,方便追溯。
写入时用 Python 批量处理,伪代码如下:
from pgvector.sqlalchemy import Vector from sqlalchemy import create_engine, text engine = create_engine("postgresql+psycopg://user:pass@host:5432/db") def insert_chunk(doc_id, index, content, embedding): with engine.begin() as conn: conn.execute( text(""" INSERT INTO doc_chunks (document_id, chunk_index, content, embedding) VALUES (:doc_id, :index, :content, :embedding::vector) """), { "doc_id": doc_id, "index": index, "content": content, "embedding": "[" + ",".join(map(str, embedding)) + "]", } )当然,生产环境一般会写成批量插入,每批 500 到 1000 条,放在事务里提交,能明显提升写入速度。
3.3 混合查询:SQL 怎么写
混合查询的核心是同时执行两条检索 SQL,然后在应用层做融合。我习惯把它封装成一个返回候选列表的函数。
向量召回 SQL:
SELECT id, document_id, content, 1 - (embedding <=> :query_embedding) AS vector_score FROM doc_chunks ORDER BY embedding <=> :query_embedding LIMIT 50;这里的<=>是余弦距离运算符,距离越小越相似。要转成相似度,可以粗略用1 - distance。
BM25 召回 SQL:
SELECT id, document_id, content, ts_rank(content_tsv, query) AS bm25_score FROM doc_chunks, plainto_tsquery('zhparser', :query_text) AS query WHERE content_tsv @@ query ORDER BY bm25_score DESC LIMIT 50;@@表示全文检索匹配,ts_rank计算相关性分。需要注意,plainto_tsquery会把用户输入转成 AND 关系,适合查询词较少的情况。如果用户输入长句,可以改用websearch_to_tsquery,它支持引号和逻辑符号。
应用层融合的逻辑,我写成了一个简单函数:
def rrf_fusion(vec_results, kw_results, k=60): scores = {} for rank, item in enumerate(vec_results): doc_id = item["id"] scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1) # 顺手把两条结果里的原始信息存进去 for rank, item in enumerate(kw_results): doc_id = item["id"] scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1) fused = sorted(scores.items(), key=lambda x: x[1], reverse=True) return fused[:20]严格来说,RRF 里的排名应该从 1 开始,上面代码里rank + 1就是做这件事。两个列表里没出现的文档不参与打分,天然实现了过滤。
3.4 HNSW 索引创建与资源估算
查询性能真正依赖的是索引。我们使用 HNSW 时创建索引的 SQL 是:
CREATE INDEX idx_doc_chunks_embedding_hnsw ON doc_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);vector_cosine_ops对应余弦距离。如果使用 L2 或内积距离,索引操作符类需要相应改成vector_l2_ops或vector_ip_ops,并且查询时使用<-?>或<#>运算符。一定要保证索引定义、查询运算符、距离函数三者一致,否则索引根本不生效,查询慢到怀疑人生。
资源估算上,一条 768 维 float 向量本身约768 * 4字节,即 3 KB;HNSW 索引会额外存储图连接信息,大概是原始数据量的 1.2 到 1.5 倍。100 万条向量,原始数据约 3 GB,索引约 4.5 GB,加上 PostgreSQL 缓存和其他数据,建议给实例分配 8 GB 到 12 GB 内存,量级并不夸张。
ef_search是查询时的关键参数,可以先设成 40。如果发现召回率不足,再调到 100。调大并不影响数据一致性,只是搜索时多探索几条路径,延迟会涨一些。
3.5 增量写入和索引维护
文档每天都会新增和更新,增量写入比一次性批量写入更考验设计。
更新文档时,我习惯先删除旧块,再插入新块,整个过程在事务里完成:
BEGIN; DELETE FROM doc_chunks WHERE document_id = :doc_id; -- 重新读取文档内容,切块、向量化,然后重新插入 INSERT INTO doc_chunks ... COMMIT;如果直接更新块文本,HNSW 索引会自动维护,不需要手动重建。但有一个容易忽略的问题:content_tsv是生成列,它只会在content被 UPDATE 时自动重新计算。如果你用“删除再插入”的方式,记得把新内容完整带上。
批量导入大量数据时,可以先删除索引,导入完成后再一次性创建索引,比边插边建索引快很多。但如果业务需要实时可查,就只能接受边插边建的代价。生产环境建议在低峰期做大数据量导入,并设置足够的maintenance_work_mem给索引构建过程。
4. 常见问题与排障经验实录
4.1 查询结果不理想,先定位是哪一路召回出了问题
混合检索调优的第一步,是把两路召回结果单独拿出来看。我的调试方法很简单:先只跑向量召回,看看 Top 10 里有没有正确答案;再只跑 BM25 召回,看 Top 10 里有没有正确答案。如果两路里各有正确答案,但融合后反而没了,那是融合逻辑的问题。如果某一路本身就全错,那是那一路上游的问题,不要急着调融合参数。
最常见的现象是:向量召回质量可以,但 BM25 召回里全是“听起来相关但不精确”的文档。这时候大概率是分词器把关键专业词切碎了。比如“混合云容灾”被切成“混合”“云”“容灾”,导致多个文档都能命中,真正包含完整术语“混合云容灾”的文档反而排名上不去。
解决办法是把这类词加进自定义词典。对 zhparser 来说,可以通过配置文件维护扩展词库。把“混合云容灾”作为整体词加入后,分词结果会更合理,BM25 排序也会立即改善。
4.2 向量索引没生效,查询还是全表扫描
很多人在小数据量阶段觉得 pgvector 查询还行,等到数据量涨到几十万,突然变慢,一看执行计划发现还是顺序扫描。原因通常有以下几种:
- 表中没有建 HNSW 索引,或者建了另一个距离运算符的索引;
- SQL 里用了
::vector做隐式转换,导致无法走索引; - 查询条件加了类似
WHERE content = ...的其他过滤条件,索引被绕过; - 向量列本身是
text类型,每次转vector,那一定不会走索引。
排查方法是用EXPLAIN ANALYZE看执行计划:
EXPLAIN ANALYZE SELECT id, content FROM doc_chunks ORDER BY embedding <=> :query_embedding LIMIT 20;如果看到Seq Scan on doc_chunks,就要回去检查索引定义。HNSW 索引对查询数量有要求,太少的数据量时优化器也可能选择顺序扫描,这属于正常现象,不代表索引没用。
如果确实要强制走索引,可以在 ORDER BY 上做文章,但一般情况下不要这么做,优化器的选择大多是合理的。即使强制走索引,如果候选区域太小,效果也不会比顺序扫描好。
4.3 中文全文检索查不到,或者匹配过少
BM25 召回查不到,先确认分词结果。执行下面这句,看看索引里存的 token 是什么:
SELECT to_tsvector('zhparser', '容灾切换方案 第三版');如果结果只显示“容”“灾”“切换”“方案”,说明自定义词典没生效。认真维护业务词表是中文检索绕不开的一步,不维护就不要怪检索效果差。
还有一种是“为什么我搜‘退款’搜不到‘退费’”。这类问题已经超出了关键词匹配的范畴,更合适用同义词配置解决。PostgreSQL 全文检索支持同义词词典,可以配置一组同义词:
退款 退费 返还资金配置后,无论用户搜三个词里的哪一个,系统都会把它们统一成同一组 token 参与匹配,大幅提升召回率。这个机制和启发式规则很相似,值得在项目初期就配置好。
4.4 RRF 融合后重复内容太多
当同一篇文档被切成多个块后,经常会出现两路召回各自返回同一个文档的不同块,RRF 排序不会感知“块属于同一文档”这一事实,结果就是最终 Top 10 里同一文档占了好几个位置。这会浪费大模型的输入空间。
我的处理是把融合粒度从“块”提高到“文档”。具体做法是:两路召回都先按块返回 Top 50,然后应用层合并时,按document_id聚合,同一文档的多个块只保留 RRF 得分最高的一个块参与最终排序。这样 Top 10 里能覆盖更多不同文档,信息密度更高。
如果业务上确实希望同一文档的多块同时进入 Prompt,可以在聚合时设置每篇文档最多保留 2 块,并按块在原文中的顺序排列,避免上下文断裂。
4.5 延迟和并发怎么优化
混合检索本身是两路查询加应用层融合,比单路查询天然多一次数据库开销。优化方向有几个:
- 向量召回和 BM25 召回并发执行。两个查询没有依赖关系,可以在应用层用协程或线程并发调用,总耗时就变成 max(两路耗时),而不是 sum。
- 为只查询所需字段建立覆盖索引。HNSW 索引不直接覆盖
content字段,查询时拿内容还需要回表。如果业务允许,可以先只查id和embedding,拿到 id 列表后再批量从表里取 content,减少大字段传输。 - 合理设置 PostgreSQL 的共享缓冲区。用于全文检索的 GIN 索引和 HNSW 索引都依赖缓存,把
shared_buffers调到内存的 1/4 左右,表现会好很多。 - 长时间不用的历史文档可以单独归档表。这样活跃表体积小,索引扫描速度更快。
并发一上来,还有一个容易忽略的点:ts_rank计算是 CPU 密集操作,如果查询中文档块数量很大,可以适当降低 BM25 召回数量,从 50 降到 30。实测对最终效果影响不大,但延迟数据会好看很多。
4.6 版本和模型一致性问题
向量模型更新换代很快,如果某天你决定换一个效果更好的 embedding 模型,最大的问题不是换模型,而是旧索引里已经存了旧模型的向量。新旧向量都塞在同一列里,计算距离没有意义,甚至会把结果完全搅乱。
遇到这种情况,最简单的方法是优雅版本化。每个向量块表都需要有一个模型版本字段。查询时需要限定只查当前版本的向量:
SELECT id, content, 1 - (embedding <=> :query_embedding) AS score FROM doc_chunks WHERE model_version = :current_model_version ORDER BY embedding <=> :query_embedding LIMIT 50;切换模型时,可以用离线任务批量重算 embedding,并在低峰期切换current_model_version,让新向量平滑接管查询。这个设计不复杂,但能省下后续大量返工时间。
5. 生产运行后的沉淀和一些个人的选择建议
项目从开发到上线大概用了三周,目前系统每天处理几千次内部检索请求,PostgreSQL 单实例扛得很稳。最让我感触深的不是“pgvector 比商用向量库更好”,而是“大多数企业 RAG 项目根本用不到超大规模向量引擎”。评估检索架构时,先别被“百万向量秒级响应”这种宣传词带走,而是问几个更实际的问题:你的数据总量有多大?检索并发有多高?团队能不能同时维护好两套存储?模型多久换一次?如果答案都偏保守,那 PG 这套组合的性价比和运维友好度会高得多。
BM25 部分让我学到的一点是:检索质量的上限,往往不是算法,而是前置的分词、词库和数据结构设计。专用向量库能解决“相似语义召回”的一半问题,但另一半,也就是“精确关键词到文档的映射”,最终还是需要 BM25 这类精确方法补位。两者的合流,才是生产级 RAG 的扎实地基。
如果你也打算走这条路,我的最后一条建议是:先不要追求一步到位,把表结构、分词器、RRF 融合接口先跑通,再用真实文档做一轮“人工标注+案例复盘”。这个成本不高,但能让你在采购任何商业化组件之前,先用数据说服需求方和技术评审。最后你会发现,很多昂贵方案的问题,本质上都能从工程细节里找到更轻的答案。