外观
从 62% 到 91%:pgvector RRF 混合检索实战
为什么纯向量检索会让知识库“答非所问”
很多团队搭建知识库问答系统时,第一版通常是“切片 + Embedding + pgvector Top-K 相似度搜索”。这种方案语义泛化能力强,用户问“怎么重置密码”,即使文档写的是“恢复登录凭证”,也可能被召回。
但真实知识库往往包含大量精确信息:错误码、函数名、配置项、SQL 关键字、产品编号、接口字段。向量模型会把这些高区分度 token 压缩成模糊语义,导致两个典型问题:
- 精确术语被泛化:查询
RRF-1024、shared_buffers、jsonb_path_query时,语义相近但不含该术语的文档反而排在前面。 - 短查询缺少上下文:用户只输入“归档 WAL”,向量空间中“备份”“日志”“复制槽”等内容都可能靠近。
因此,更稳妥的方案是同时保留两路召回:
- PostgreSQL 全文检索负责关键词、短语和精确匹配;
- pgvector 负责语义相似匹配;
- 使用 RRF(Reciprocal Rank Fusion,倒数排名融合)合并两个排序结果。
本文用一套可直接运行的 SQL,演示如何在 PostgreSQL 中实现混合检索,并解释为什么 RRF 往往比手工加权分数更适合知识库。
方案对比:纯向量、加权分数与 RRF
纯向量检索
纯向量方案结构简单,只需要一个 HNSW 或 IVFFlat 索引。它对自然语言问题友好,但对短词、专有名词、编号类查询不稳定。
全文检索 + 向量分数加权
常见写法是把 ts_rank_cd 和余弦相似度归一化后相加,例如:
sql
SELECT id, 0.3 * fulltext_score + 0.7 * vector_score AS score
FROM docs;问题在于两路分数尺度不同。全文分数可能在 0 到 1 之间,向量距离可能是 0.2 到 0.8,换一个 Embedding 模型后分布还会变化。权重 0.3/0.7 往往需要反复试验,且难以跨业务迁移。
RRF 融合排序
RRF 不关心原始分数,只关心每一路结果中的名次:
text
RRF_score(d) = Σ 1 / (k + rank_i(d))其中 rank_i(d) 是文档在第 i 路召回中的名次,k 是平滑常数,常见取值为 60。某文档在全文结果排第 2,在向量结果排第 5,则其融合分数为:
text
1/(60+1) + 1/(60+4) ≈ 0.0320RRF 的优势是:
- 不依赖分数归一化;
- 对 Embedding 模型替换更稳健;
- 能同时奖励“关键词命中”和“语义命中”;
- SQL 实现简单,便于在 PostgreSQL 内完成。
下面是混合检索的整体流程:
环境准备:安装 pgvector 并建立知识库表
以下示例基于 PostgreSQL 15+ 和 pgvector 0.7+。先安装扩展:
sql
CREATE EXTENSION IF NOT EXISTS vector;创建知识库文档表。为了便于演示,这里使用 3 维向量;生产环境通常是 768、1024、1536 或 3072 维。
sql
DROP TABLE IF EXISTS kb_docs CASCADE;
CREATE TABLE kb_docs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(3) NOT NULL,
-- 维护全文检索向量,支持中文时建议配合 zhparser/jieba,
-- 或在应用层对中文分词后再写入 tsvector。
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(content, '')), 'B')
) STORED
);插入几条模拟文档。真实项目中,embedding 通常由应用调用 Embedding 模型生成,再作为参数传入 SQL。
sql
INSERT INTO kb_docs (title, content, embedding) VALUES
(
'pgvector 安装指南',
'介绍如何安装 pgvector 扩展,并创建 vector 类型字段。',
'[0.10, 0.20, 0.90]'
),
(
'PostgreSQL 索引调优',
'讲解 B-tree、GIN、BRIN 索引选择,以及 EXPLAIN 分析。',
'[0.80, 0.15, 0.20]'
),
(
'RRF 混合检索原理',
'混合检索合并全文检索和向量检索结果,RRF 按排名计算分数。',
'[0.20, 0.85, 0.25]'
),
(
'知识库召回率评估',
'使用命中率、MRR、NDCG 评估知识库问答系统的检索质量。',
'[0.30, 0.75, 0.35]'
),
(
'JSONB 查询实践',
'介绍 jsonb_path_query、GIN 索引和 JSONB 包含查询。',
'[0.75, 0.25, 0.10]'
);创建索引:GIN 与 HNSW 同时加速
全文检索使用 GIN 索引;向量检索优先考虑 HNSW,适合高召回、低延迟的在线查询:
sql
CREATE INDEX idx_kb_docs_search_vector
ON kb_docs
USING GIN(search_vector);
CREATE INDEX idx_kb_docs_embedding_hnsw
ON kb_docs
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);如果使用 IVFFlat,需要在数据导入后再建索引,并合理设置 lists。数据量较小时,HNSW 通常更省心。
查询时还可以调整 HNSW 搜索范围:
sql
SET hnsw.ef_search = 80;ef_search 越大,召回率通常越高,但扫描成本也越高。不要在没有压测的情况下直接设置成几百甚至上千。
SQL 实现 RRF 混合检索
下面这条 SQL 完成三件事:
- 用
plainto_tsquery做全文 Top-N; - 用 pgvector 余弦距离做向量 Top-N;
- 对两路结果分别使用
ROW_NUMBER()排名,再按 RRF 公式聚合。
sql
WITH params AS (
SELECT
-- 实际应用中由应用层生成 query embedding
'[0.25, 0.80, 0.30]'::vector AS query_embedding,
plainto_tsquery('simple', 'RRF 混合检索 知识库') AS query_text,
60 AS rrf_k,
20 AS candidate_n
),
fulltext_hits AS (
SELECT
d.id,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(d.search_vector, p.query_text) DESC, d.id
) AS rank_num
FROM kb_docs d
CROSS JOIN params p
WHERE d.search_vector @@ p.query_text
ORDER BY rank_num
LIMIT 20
),
vector_hits AS (
SELECT
d.id,
ROW_NUMBER() OVER (
ORDER BY d.embedding <=> p.query_embedding, d.id
) AS rank_num
FROM kb_docs d
CROSS JOIN params p
ORDER BY d.embedding <=> p.query_embedding
LIMIT 20
),
fused AS (
SELECT
id,
SUM(1.0 / (p.rrf_k + rank_num)) AS rrf_score,
MIN(rank_num) AS best_rank,
COUNT(*) AS hit_channels
FROM (
SELECT id, rank_num FROM fulltext_hits
UNION ALL
SELECT id, rank_num FROM vector_hits
) u
CROSS JOIN params p
GROUP BY id
)
SELECT
d.id,
d.title,
left(d.content, 80) AS content_preview,
f.rrf_score,
f.hit_channels,
f.best_rank
FROM fused f
JOIN kb_docs d ON d.id = f.id
ORDER BY
-- 同时命中两路的结果优先,其次看 RRF 分数
f.hit_channels DESC,
f.rrf_score DESC,
f.best_rank ASC
LIMIT 10;在示例数据中,标题为“RRF 混合检索原理”的文档既包含精确关键词,又与查询向量接近,因此会同时出现在两路候选中,通常能稳定排在第一。
封装成可复用 SQL 函数
为了让应用层只传查询文本、查询向量和候选数量,可以封装一个函数:
sql
CREATE OR REPLACE FUNCTION search_kb_hybrid(
query_text text,
query_embedding vector(3),
candidate_n int DEFAULT 20,
result_n int DEFAULT 10,
rrf_k int DEFAULT 60
)
RETURNS TABLE (
id bigint,
title text,
rrf_score double precision,
hit_channels bigint
)
LANGUAGE sql
AS $$
WITH fulltext_hits AS (
SELECT
d.id,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(d.search_vector, plainto_tsquery('simple', query_text)) DESC, d.id
) AS rank_num
FROM kb_docs d
WHERE d.search_vector @@ plainto_tsquery('simple', query_text)
ORDER BY rank_num
LIMIT candidate_n
),
vector_hits AS (
SELECT
d.id,
ROW_NUMBER() OVER (
ORDER BY d.embedding <=> query_embedding, d.id
) AS rank_num
FROM kb_docs d
ORDER BY d.embedding <=> query_embedding
LIMIT candidate_n
),
fused AS (
SELECT
u.id,
SUM(1.0 / (rrf_k + u.rank_num))::double precision AS rrf_score,
COUNT(*) AS hit_channels
FROM (
SELECT id, rank_num FROM fulltext_hits
UNION ALL
SELECT id, rank_num FROM vector_hits
) u
GROUP BY u.id
)
SELECT
d.id,
d.title,
f.rrf_score,
f.hit_channels
FROM fused f
JOIN kb_docs d ON d.id = f.id
ORDER BY f.hit_channels DESC, f.rrf_score DESC
LIMIT result_n;
$$;调用方式:
sql
SELECT *
FROM search_kb_hybrid(
'RRF 混合检索 知识库',
'[0.25,0.80,0.30]'::vector,
20,
10,
60
);生产环境可以把函数中的表名、向量维度、分词配置、权限和返回字段按业务调整。不要把 Embedding API 调用放进 PostgreSQL 函数内部,数据库应专注于检索与排序。
如何判断混合检索真的提升了命中率
不要只凭几个示例问题判断效果。建议构建一个 100 到 500 条的评估集,每条数据包含:
- 用户真实问题;
- 标准文档 ID 或可接受文档集合;
- 问题类型,如关键词型、语义型、编号型、长句型。
分别运行纯全文、纯向量、RRF 混合检索,比较以下指标:
- Hit@K:Top-K 中是否包含正确文档;
- MRR:正确文档排名越靠前分数越高;
- NDCG@K:适合多个相关文档且相关程度不同的场景。
一个常见经验是:纯向量在语义型问题上较强,但在错误码、配置名、API 名称类问题上波动明显;RRF 混合检索通常能在不明显损害语义召回的情况下,把整体 Hit@5 或 Hit@10 拉高。
避坑总结:RRF 落地时最容易踩的 5 个坑
1. 中文没有正确分词,全文路几乎失效
错误现象:查询“混合检索知识库”没有命中包含“混合检索”和“知识库”的文档,EXPLAIN 显示全文候选很少。
原因:PostgreSQL 默认 simple 配置不会像中文搜索引擎那样切词,中文短语可能被当成一个整体 token。
修正方法:接入 zhparser、pg_jieba 等中文分词扩展,或在应用层完成中文分词,将空格分隔后的 token 写入 tsvector。同时保留标题字段的更高权重。
2. 向量维度或距离算子与索引不一致
错误现象:建了 HNSW 索引但查询走顺序扫描,或者写入时报错:expected N dimensions, not M。
原因:表中 vector(N) 的维度与模型输出不一致;或者索引用 vector_cosine_ops,查询却使用 <-> 欧氏距离。
修正方法:固定 Embedding 模型版本和维度;余弦距离使用 <=>,对应 vector_cosine_ops;内积使用 <#>,对应 vector_ip_ops。更换模型后必须全量重建 embedding。
3. 候选 Top-N 太小,RRF 没有足够材料融合
错误现象:最终只返回 10 条,于是每一路也只取 10 条,导致某些只在第 15 名出现的高相关文档完全丢失。
原因:把最终返回数和候选召回数混为一谈。
修正方法:每一路召回建议取最终结果数的 3 到 10 倍。例如最终返回 10 条,每一路可先召回 50 到 100 条,再由 RRF 融合排序。
4. 直接相加原始分数,导致某一路“碾压”另一路
错误现象:全文分数范围很小,向量相似度普遍在 0.8 以上,最终排序几乎等同于纯向量检索。
原因:不同检索器的分数分布不可比,简单加权没有排序意义。
修正方法:优先使用 RRF 这类基于名次的融合。如果业务确实需要加权,可以给不同通道设置权重,例如全文命中加 1.2/(k+rank),但要用评估集验证,而不是凭感觉调参。
5. 只看平均延迟,不检查过滤条件与索引组合
错误现象:知识库加上权限、租户、文档状态过滤后,延迟明显升高,甚至先扫描大量向量再过滤。
原因:候选集合需要同时满足业务过滤和相似度排序,单列向量索引不能自动解决所有过滤问题。
修正方法:高频强过滤字段可考虑分区、部分索引,或 pgvector 支持的迭代扫描能力;上线前用 EXPLAIN (ANALYZE, BUFFERS) 检查实际执行路径,并按租户、文档状态、更新时间建立贴近查询模式的索引。
结语
pgvector 的价值不只是给 PostgreSQL 增加向量搜索能力,更在于让关键词检索、语义检索、业务过滤和融合排序可以在同一个数据库中完成。对于知识库场景,建议从“全文 GIN + 向量 HNSW + RRF”这条稳健路径开始,再用真实问题集持续评估 Hit@K、MRR 和延迟。
当你发现纯向量问答偶尔精准、偶尔漂移时,不必急于更换复杂检索中间件。很多时候,补上一路可靠的关键词召回,并用 RRF 按名次融合,就能以较低成本显著提升知识库命中率。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。