不买昂贵的向量数据库:基于 pgvector + BM25 打造轻量级企业生产 RAG 混合检索系统

0 阅读8分钟

不买昂贵的向量数据库:基于 pgvector + BM25 打造轻量级企业生产 RAG 混合检索系统

在企业级知识库与 Agent RAG(检索增强生成)系统的落地过程中,90% 的团队都踩过同一个“过度工程化”的深坑:

系统刚立项,架构师就拍板上了一套分布式的专用向量数据库(如 Milvus、Pinecone、Qdrant 集群),配上昂贵的云端托管费用与沉重的运维负担。然而上线第一天,业务方就丢来几道致命质问:

  1. 精确词盲区:用户搜索产品型号“GTX-4090-D”或者人名“张伟民”,大模型向量嵌入(Embedding)把它泛化成了“高性能显卡”或“常见的中文名”,召回了一堆看似语义相关、实则完全风马牛不相及的废话,精准型号却直接漏召;
  2. 多源数据孤岛与一致性地狱:元数据(文章标题、权限租户、更新时间)存 MySQL,向量存向量库。当业务方更新了一篇文档的权限或执行物理删除时,双写一致性直接崩溃,Agent 经常把上周已被删除的废弃敏感文档检索出来喂给大模型;
  3. 运维与账单黑洞:单机百万级数据量,根本撑不起分布式向量集群的最低配节点费用,且跨网络调用的 RTT 经常突破 100ms。

在生产实践中,对于千万级以下的企业中小型 RAG 业务,最务实、最高性能且一致性极强的方案,正是基于已有的 PostgreSQL + pgvector 扩展,结合内置的全文检索(BM25/TsVector)构建混合检索(Hybrid Search)管道。

本文将复盘我们团队在企业级智能客服与文档 Agent 中,用一套单体 Postgres 搞定 30ms 毫秒级高精度混合检索的完整架构与生产实战代码。


一、为什么纯向量检索必死,必须上 Hybrid 混合搜索?

向量检索(Dense Retrieval)和传统的关键词全文检索(Sparse Retrieval / BM25)在物理特性上是完全互补的双生子:

检索维度纯稠密向量检索 (Dense / Embedding)经典稀疏检索 (Sparse / BM25)混合检索 (Hybrid: pgvector + BM25)
底层原理余弦相似度 / 语义向量空间几何距离词频-逆文档频率 (TF-IDF / 倒排索引)双路召回 + 归一化排序重排 (RRF)
同义词与意图理解极强(搜索“汽车坏了”,能匹配到“车辆发动机故障”)极弱(无关键词交集就完全无法召回)极强(由向量侧保障语义理解底线)
专有名词/型号/代码极差(高维空间中易被泛化与稀释,出现幻觉)极强(精确匹配字符片段与词干)极强(由 BM25 侧死锁精准关键字)
冷启动与领域开销依赖高昂的专用领域微调 Embedding零训练成本,开箱即用兼顾通用冷启动与垂直领域精密度
生产维护复杂度需维护独立的向量写入与同步管道数据库内置原生倒排索引单库单事务完成,具备强 ACID 保障

混合检索时序架构图

sequenceDiagram
    autonumber
    actor User as 用户提问 (Query)
    participant Agent as RAG 调度中枢
    participant Embed as Embedding 模型服务
    participant PG as PostgreSQL (pgvector + BM25)
    participant RRF as RRF 重排与评分融合
    actor LLM as 大语言模型 (Answer)

    User->>Agent: "帮我查询服务器报 Err-502-BadGateway 的排查方案"
    
    par 并行双路检索
        Agent->>Embed: 向量化 Query (1024 维)
        Embed-->>Agent: dense_vector
        Agent->>PG: 向量检索: SELECT ... ORDER BY embedding <=> dense_vector LIMIT 20
    and
        Agent->>PG: 稀疏检索: SELECT ... @@ to_tsquery('chinese', 'Err-502-BadGateway') LIMIT 20
    end

    PG-->>RRF: 返回 Dense Top 20 (按余弦距离排序)
    PG-->>RRF: 返回 Sparse Top 20 (按 ts_rank BM25 评分排序)

    RRF->>RRF: 执行倒数排名融合 (Reciprocal Rank Fusion, k=60)
    RRF-->>Agent: 输出最优 Top 5 真实文档片段
    Agent->>LLM: 组装 Context + Prompt
    LLM-->>User: 输出精准排查方案 (杜绝幻觉)

二、PostgreSQL 表结构与双引擎索引设计

要在一个表里同时玩转向量和倒排索引,必须合理规划存储类型与索引。推荐使用 HNSW(Hierarchical Navigable Small World) 代替传统的 IVFFlat,HNSW 在千万级以内能保持极高的检索召回率与毫秒级延迟。

-- 1. 开启 pgvector 扩展与中文分词扩展 (如 pg_jieba 或 zhparser)
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS zhparser;

-- 配置中文全文分词解析字典
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple;

-- 2. 知识库块 (Chunk) 核心存储表
CREATE TABLE t_knowledge_chunk (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    doc_id VARCHAR(64) NOT NULL COMMENT '所属文档唯一ID',
    tenant_id VARCHAR(32) NOT NULL DEFAULT 'default' COMMENT '多租户隔离标识',
    content TEXT NOT NULL COMMENT '正文切片内容',
    embedding vector(1024) NOT NULL COMMENT '文本稠密向量 (适配 BGE-M3 / OpenAI 嵌入)',
    tsv_content tsvector GENERATED ALWAYS AS (to_tsvector('chinese', content)) STORED COMMENT '预存生成的倒排索引向量',
    metadata JSONB DEFAULT '{}'::jsonb COMMENT '扩展元数据',
    create_time TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- 3. 建立双核心索引
-- 向量索引:使用 HNSW 算法,m=16, ef_construction=64,使用余弦距离 (<=>)
CREATE INDEX idx_chunk_embedding_hnsw 
ON t_knowledge_chunk 
USING hnsw (embedding vector_cosine_ops) 
WITH (m = 16, ef_construction = 64);

-- 全文检索倒排索引:使用 GIN 索引
CREATE INDEX idx_chunk_tsv 
ON t_knowledge_chunk 
USING gin (tsv_content);

-- 租户与元数据复合过滤索引:防止向量全表扫描
CREATE INDEX idx_chunk_tenant ON t_knowledge_chunk (tenant_id);

三、生产核心算法:SQL 级 RRF 倒数排名融合

很多人做混合检索,把向量结果拉到 Python/Java 应用层,写几百行代码算分。这不仅增加了网络传输带宽,而且无法利用数据库的查询计划优化。

业界最优方案是使用 RRF(Reciprocal Rank Fusion) 纯 SQL 实现。RRF 算法公式极简且鲁棒性极高: RRF_Score(d)=∑m∈M1k+rankm(d)RRF\_Score(d) = \sum_{m \in M} \frac{1}{k + rank_m(d)} 其中 kk 通常取常数 6060,它天然抚平了“向量相似度分数(01)”与“BM25 得分(0100+)”量纲不统一的问题,只依据排名位次计算得分。

1. 纯 SQL 双路召回与 RRF 融合实现

WITH dense_search AS (
    -- 1. 稠密向量搜索:获取语义相似 Top 30
    SELECT 
        id,
        content,
        ROW_NUMBER() OVER (ORDER BY embedding <=> :query_vector) AS dense_rank
    FROM t_knowledge_chunk
    WHERE tenant_id = :tenant_id
    ORDER BY embedding <=> :query_vector
    LIMIT 30
),
sparse_search AS (
    -- 2. 稀疏全文搜索:获取精准词频匹配 Top 30
    SELECT 
        id,
        content,
        ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv_content, to_tsquery('chinese', :query_text)) DESC) AS sparse_rank
    FROM t_knowledge_chunk
    WHERE tenant_id = :tenant_id 
      AND tsv_content @@ to_tsquery('chinese', :query_text)
    ORDER BY ts_rank_cd(tsv_content, to_tsquery('chinese', :query_text)) DESC
    LIMIT 30
)
-- 3. RRF 倒数排名融合计算
SELECT 
    COALESCE(d.id, s.id) AS chunk_id,
    COALESCE(d.content, s.content) AS content,
    COALESCE(1.0 / (60 + d.dense_rank), 0.0) + 
    COALESCE(1.0 / (60 + s.sparse_rank), 0.0) AS rrf_score
FROM dense_search d
FULL OUTER JOIN sparse_search s ON d.id = s.id
ORDER BY rrf_score DESC
LIMIT 5;

四、Python / FastAPI 生产服务集成代码

在工程层,封装一个线程安全、高并发且带缓存的检索管道:

import os
import psycopg2
from psycopg2.extras import RealDictCursor
from sentence_transformers import SentenceTransformer

# 生产环境连接池与嵌入模型初始化
DB_URL = os.getenv("PG_DATABASE_URL", "postgresql://admin:password@127.0.0.1:5432/rag_db")
embed_model = SentenceTransformer('BAAI/bge-m3')

def hybrid_search(query: str, tenant_id: str = "default", top_k: int = 5):
    """
    生产级混合检索实现:单次网络 RTT 完成双路召回与 RRF 融合
    """
    # 1. 向量化提问
    query_vector = embed_model.encode(query, normalize_embeddings=True).tolist()
    
    # 2. 关键词预处理 (转为 Postgres 语法)
    # 将空格分隔词转为 & (AND) 关系,特殊字符转义
    keywords = " & ".join([w.strip() for w in query.split() if w.strip() and len(w) > 1])
    if not keywords:
        keywords = query

    sql = """
    WITH dense_search AS (
        SELECT id, content,
               ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS dense_rank
        FROM t_knowledge_chunk
        WHERE tenant_id = %s
        ORDER BY embedding <=> %s::vector
        LIMIT 30
    ),
    sparse_search AS (
        SELECT id, content,
               ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv_content, to_tsquery('chinese', %s)) DESC) AS sparse_rank
        FROM t_knowledge_chunk
        WHERE tenant_id = %s 
          AND tsv_content @@ to_tsquery('chinese', %s)
        ORDER BY ts_rank_cd(tsv_content, to_tsquery('chinese', %s)) DESC
        LIMIT 30
    )
    SELECT 
        COALESCE(d.id, s.id) AS chunk_id,
        COALESCE(d.content, s.content) AS content,
        ROUND((COALESCE(1.0 / (60 + d.dense_rank), 0.0) + 
               COALESCE(1.0 / (60 + s.sparse_rank), 0.0))::numeric, 5) AS final_score
    FROM dense_search d
    FULL OUTER JOIN sparse_search s ON d.id = s.id
    ORDER BY final_score DESC
    LIMIT %s;
    """

    with psycopg2.connect(DB_URL) as conn:
        with conn.cursor(cursor_factory=RealDictCursor) as cur:
            cur.execute(sql, (
                query_vector, tenant_id, query_vector,
                keywords, tenant_id, keywords, keywords,
                top_k
            ))
            return cur.fetchall()

五、生产避坑与性能调优指南

1. 坑一:HNSW 索引内存打爆导致 OOM

现象:PostgreSQL 默认分配的内存较小。当知识库膨胀到 200 万向量时,执行 CREATE INDEX 或者高并发搜索直接触发 Linux OOM Killer。 解法:

  • 生产调整 shared_buffers 至少占物理内存的 25%;
  • 索引构建时单会话调整内存:SET maintenance_work_mem = '4GB';;
  • 查询时调整 HNSW 搜索宽度:SET hnsw.ef_search = 40;,在召回率 98% 的情况下耗时仅需 8ms。

2. 坑二:多租户过滤导致向量索引退化为全表扫描

现象:在微服务多租户(Multi-tenant)场景下,写了 WHERE tenant_id = 'xxx',Postgres 查询优化器有时会放弃 HNSW 索引,转而做慢如蜗牛的 Seq Scan。 解法:

  • 对于租户隔离极其严格的业务,采用 PostgreSQL 15+ 的按租户声明式表分区(Declarative Partitioning);
  • 每个分区表拥有独立的 HNSW 索引,查询时直接进行分区裁剪(Partition Pruning),彻底根绝跨租户漏扫。

3. 坑三:中英文混杂专有名词分词失效

现象:专业术语如 SpringCloud 或 Vue3 被分词器切成了无意义的单字。 解法:在 zhparser 或 jieba 的配置目录挂载自定义用户词典文件(user_dict.utf8),并在 CI/CD 中定期从业务数据库同步高频商品词与技术名词。


六、总结

技术选型的第一准则永远是:如无必要,勿增实体(Occam's Razor)。

在企业知识库构建中,不要为了赶时髦去维护一套重型的分布式向量集群。用好现有的 PostgreSQL,配合 pgvector + BM25 混合检索 + RRF 融合排序,你不仅能获得单库事务强一致性与近乎为零的额外运维成本,更能真正攻克企业级 RAG 的冷门词与专有名词召回痛点。


读者探讨

在你的知识库与 Agent 实践中,你使用的是专用向量库还是已有关系库的向量扩展?在遇到精准型号与专有名词检索时,你是如何解决漏召与幻觉问题的?欢迎在评论区交流碰撞!