跳到主内容

从 62% 到 91%:pgvector RRF 混合检索实战 ​

为什么纯向量检索会让知识库“答非所问” ​

很多团队搭建知识库问答系统时,第一版通常是“切片 + Embedding + pgvector Top-K 相似度搜索”。这种方案语义泛化能力强,用户问“怎么重置密码”,即使文档写的是“恢复登录凭证”,也可能被召回。

但真实知识库往往包含大量精确信息:错误码、函数名、配置项、SQL 关键字、产品编号、接口字段。向量模型会把这些高区分度 token 压缩成模糊语义,导致两个典型问题:

  • 精确术语被泛化:查询 RRF-1024、shared_buffers、jsonb_path_query 时,语义相近但不含该术语的文档反而排在前面。
  • 短查询缺少上下文:用户只输入“归档 WAL”,向量空间中“备份”“日志”“复制槽”等内容都可能靠近。

因此,更稳妥的方案是同时保留两路召回:

  1. PostgreSQL 全文检索负责关键词、短语和精确匹配;
  2. pgvector 负责语义相似匹配;
  3. 使用 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.0320

RRF 的优势是:

  • 不依赖分数归一化;
  • 对 Embedding 模型替换更稳健;
  • 能同时奖励“关键词命中”和“语义命中”;
  • SQL 实现简单,便于在 PostgreSQL 内完成。

下面是混合检索的整体流程:

用户问题关键词 + 语义全文检索召回tsvector + GIN向量检索召回pgvector + HNSWRRF 融合排序按名次合并 Top-K

环境准备:安装 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 完成三件事:

  1. 用 plainto_tsquery 做全文 Top-N;
  2. 用 pgvector 余弦距离做向量 Top-N;
  3. 对两路结果分别使用 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 按名次融合,就能以较低成本显著提升知识库命中率。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。