编程 PostgreSQL 18 原生向量搜索实战:从 pgvector 到内核级 HNSW,一套数据库搞定结构化 + 语义检索

2026-07-08 01:14:11 +0800 CST views 12

PostgreSQL 18 原生向量搜索实战:从 pgvector 到内核级 HNSW,一套数据库搞定结构化 + 语义检索

关键词:PostgreSQL 18、原生向量搜索、HNSW、pgvector、语义检索、RAG、混合检索、数据库内 AI

如果你去年做过 RAG、语义搜索或者多模态检索,大概率踩过这样一个坑:业务数据在 PostgreSQL 里,向量却要放到 Milvus/Qdrant 里,两套系统、两套备份、两套鉴权,还要写一段"先查向量库拿 ID,再回 Postgres 拿详情"的胶水代码。2026 年 7 月,PostgreSQL 18 正式把 vector 类型与 HNSW 索引下沉到内核,不再依赖 pgvector 扩展——这意味着上面那套"双库架构"可以收编成一套 SQL 了。

这篇文章不吹概念,直接从工程视角拆解:PG18 的原生向量到底改了什么、HNSW 在内核里怎么跑、怎么写混合检索(向量 + 全文 + 结构化过滤)、参数怎么调、以及它和专用向量库之间真实的取舍边界。所有 SQL 与 Python 均可在 PG18 环境直接跑。


一、背景:向量检索为什么成了数据库的"标配"

1.1 检索范式正在从"关键词"转向"语义"

传统检索靠倒排索引做关键词匹配:LIKE '%报错%' 或者 FTS 的 to_tsvector。但用户问的是"我的服务半夜总是 OOM",底层日志里可能一个"OOM"字眼都没有,只有 killed processmemory cgroup out of memory。关键词匹配在这里会直接失效。

embedding 模型把文本/图片/音频编码成一个高维实数向量,语义相近的内容在向量空间里距离更近。于是"检索"变成了"在向量空间里找最近邻"。这套能力是 RAG(检索增强生成)、智能客服、以图搜图、代码语义搜索的底座。

1.2 过去的"双库架构"及其代价

在 PG18 之前,想在 Postgres 里做向量检索只有两条路:

  1. 装 pgvector 扩展:在 Postgres 进程内跑向量类型 + IVFFlat/HNSW 索引。优点是与 SQL 融合好;缺点是它终究是个扩展,类型系统、统计信息、查询优化器对它的"理解"是打补丁式的,大表上 HNSW 构建、pre-filter 召回率等问题长期存在。
  2. 外挂专用向量库:Milvus / Qdrant / Weaviate。向量检索性能极致,但代价是系统分裂——你的订单、用户、权限在 Postgres,向量在另一套;事务一致性断在中间;运维要养两套集群;最要命的是"带业务过滤的向量查询"要做两次网络往返。

1.3 PG18 的转向:向量成为一等公民

PG18 把 vector 类型与 HNSW 索引实现进内核,而不是扩展。这件事的工程意义不是"少装一个扩展"那么简单,而是:

  • 类型系统原生认识 vector(1536),统计信息(直方图、NDV)能对向量列生效;
  • 查询规划器(planner)能像对待普通列一样评估向量谓词的选择性,生成更优的 join / filter 顺序;
  • 向量索引与 B-tree、GIN 共享同一套并发控制与 WAL 机制,事务一致性天然保证
  • 语法与 pgvector 高度兼容,老项目迁移成本极低。

下面所有示例默认你已在一个 PG18 实例上,且无需 CREATE EXTENSION vector


二、核心概念

2.1 向量、embedding 与距离度量

一个 embedding 就是一串实数,例如 OpenAI text-embedding-3-small 产出 1536 维向量,BGE-M3 产出 1024 维。在 PG18 里定义列就是:

embedding vector(1536)

"最近邻"由距离函数定义,PG18 原生支持三种距离操作符(与 pgvector 完全一致):

操作符含义适用场景
<->L2 欧氏距离已归一化/无方向性的一般向量
<=>余弦距离(1 - 余弦相似度)文本语义检索最常用
<#>负内积(越大越相似)向量已 L2 归一化时等价于余弦

注意 <=> 返回的是"距离"(越小越近),而内积 <#> 是"负内积"(越小越近,所以排序时要 ORDER BY emb <#> :q,取最小的)。这是新手最容易写反的地方。

2.2 HNSW 索引原理(为什么它快)

向量检索最朴素的做法是"暴力扫描":拿查询向量和全表每一行算距离,取 top-k。维度 1536、行数 1000 万时,这就是 1536×10^7 次浮点运算,慢到不可接受。

HNSW(Hierarchical Navigable Small World,分层可导航小世界图)用"图"换"速度":

  • 把每个向量作为一个节点,按概率分配到一个(类似跳表,顶层节点少、底层节点多);
  • 每个节点维护若干邻居边(由 m 参数控制每层最大邻居数);
  • 查询时从顶层入口出发,在每层贪心走向更近的邻居,逐层下沉,最终在底层精确定位最近邻。

复杂度从 O(N) 降到约 O(log N),代价是额外的内存(存图结构)和构建时间。它和 IVFFlat(倒排文件,先聚类再在最近簇里搜)相比,不需要在"召回率"和"查询速度"之间做那么痛苦的权衡,且支持动态插入。

HNSW 的三个核心参数:

  • m:每个节点在每层维护的邻居数。越大 → 图越密 → 召回率越高、内存越大。典型 16。
  • ef_construction:构建索引时每个新节点考察的候选邻居数。越大 → 图质量越高、构建越慢。典型 128~256。
  • ef_search查询时考察的动态候选集大小(运行时可调)。越大 → 召回越高、查询越慢。典型 40~200。

2.3 原生 vs 扩展:到底差在哪

pgvector 是 C 写的扩展,挂在 Postgres 的"自定义类型 + 自定义访问方法"钩子上。它能工作,但 planner 对它的统计是黑盒。PG18 的原生实现把向量列纳入内核统计管线

  • ANALYZE 会为向量列收集更贴合的分布信息(如各维度的近似范围),让 WHERE embedding <=> :q < 0.3 这样的谓词能被估算出合理的选择性;
  • 向量索引能和普通 B-tree 索引在同一个查询计划里被 planner 聪明地组合,比如先走 B-tree 过滤时间范围,再在结果集上跑 HNSW;
  • WAL 与检查点逻辑统一,逻辑复制、流复制对向量列零额外配置。

三、架构分析

3.1 存储布局:向量怎么落盘

PG18 的 vector(N) 采用定长头 + N 个 float4 的二进制布局,与 pgvector 的存储格式字节级兼容,这正是"迁移零成本"的底气。单行的向量字节数约为 4 + 4*N(头一个 int32 记维度)。当 N 很大(比如 1536 维约 6KB)超过 TOAST 阈值(默认 2KB)时,Postgres 会自动把向量 TOAST 到行外——但被 HNSW 索引引用的向量副本存在索引里,所以查询走索引时不会因 TOAST 而回表变慢。实践建议:超长维度(如 3072 维)配合 halfvec 量化存储以控内存。

3.2 查询计划融合:向量 KNN 如何与 SQL 过滤协同

一个真实诉求:"搜最近 30 天内、 cosine 距离小于 0.3、且属于某租户的知识库片段"。在原生实现里,planner 可以生成这样的执行路径:

Limit
  ->  Index Scan using docs_emb_hnsw on docs
        Index Cond: (embedding <=> :q)  -- HNSW 最近邻遍历
        Filter: (created_at > now() - '30 days'::interval) AND (tenant_id = 7)

关键在于:HNSW 遍历是按距离升序产出候选的,planner 在产出过程中同步应用 Filter边搜边筛。这避免了"先暴力拉 1 万行再过滤"的灾难。你可以用 EXPLAIN (ANALYZE, VERBOSE) 观察 Index CondFilter 是否如预期分离。

3.3 混合检索:向量 + 全文 + 结构化

纯粹的向量检索会丢失"精确关键词命中"(比如用户搜错误码 ERR_503)。工业级检索普遍用混合检索:向量召回负责语义,全文检索(FTS5 / tsvector)负责关键词,最后用 RRF(Reciprocal Rank Fusion,倒数排名融合)合并两份结果。PG18 原生支持让这件事在一个 SQL 事务里完成,见第四章 4.5。

3.4 与 pgvector 的兼容与差异速查

维度pgvector(扩展)PG18 原生
安装CREATE EXTENSION vector内核自带
类型vector / halfvec / bit / sparsevec同左,字节兼容
索引USING hnsw / ivfflatUSING hnsw(内核实现)
统计黑盒,需手动调planner 原生感知
事务受 Postgres 事务保护同左,无差异
生态成熟、文档多新兴,社区磨合中

结论:新项目直接上 PG18 原生;存量 pgvector 项目可平滑迁移(见 4.7)。


四、代码实战

4.1 建表:把向量和业务数据放一起

CREATE TABLE kb_chunk (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id   int          NOT NULL,
    doc_id      bigint       NOT NULL,
    content     text         NOT NULL,
    embedding   vector(1536) NOT NULL,
    created_at  timestamptz  NOT NULL DEFAULT now()
);

-- 业务过滤常用列建 B-tree
CREATE INDEX ON kb_chunk (tenant_id);
CREATE INDEX ON kb_chunk (created_at DESC);

-- 中文全文检索(以 zhcfg 中文分词配置为例)
ALTER TABLE kb_chunk ADD COLUMN content_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('zhcfg', content)) STORED;
CREATE INDEX ON kb_chunk USING gin (content_tsv);

把向量列和业务列放在同一张表,是 PG18 原生方案最大的爽点:一次查询、一份备份、一个事务

4.2 最近邻查询(KNN)

-- :q 是 1536 维查询向量,:tenant 是当前租户
SELECT id, content, embedding <=> :q AS dist
FROM kb_chunk
WHERE tenant_id = :tenant
ORDER BY embedding <=> :q
LIMIT 5;

:q 的传参格式是 '[0.12, -0.03, ..., 0.88]' 这样的方括号字符串,psycopg 会自动把 Python list 转成它。

4.3 创建 HNSW 索引并调参

-- 余弦距离空间建 HNSW;vector_cosine_ops 对应 <=> 操作符
CREATE INDEX kb_emb_hnsw ON kb_chunk
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 128);

构建期参数通过 WITH (...) 写死在索引上;查询期ef_search 是会话级 GUC,可随时调整,不影响索引本身:

SET hnsw.ef_search = 200;   -- 召回优先(慢一点)
-- 或
SET hnsw.ef_search = 40;    -- 延迟优先(召回略降)

小技巧:对已存在的大表首次建 HNSW,先 SET max_parallel_workers = 8; 和调大 maintenance_work_mem(如 2GB),能显著缩短构建时间。

4.4 带过滤的查询与"召回陷阱"

SELECT id, content
FROM kb_chunk
WHERE tenant_id = :tenant
  AND created_at > now() - interval '30 days'
  AND embedding <=> :q < 0.3
ORDER BY embedding <=> :q
LIMIT 10;

这里藏着一个经典坑:HNSW 的 pre-filter 召回率问题。HNSW 是按距离贪心遍历的,如果先用 tenant_id/created_at 过滤再搜,图遍历可能根本走不到满足条件的节点,导致"明明有更近的却没被返回"。PG18 原生实现的缓解手段:

  1. 放宽距离阈值再截断:先 ORDER BY dist LIMIT 100 拿候选,应用层或子查询再按业务条件精筛;
  2. ef_search 调大候选集,让遍历探索更充分;
  3. 对强过滤场景(如租户隔离),考虑按 tenant 分区(PARTITION BY tenant_id),让 HNSW 在分区内部构建,过滤变成"分区裁剪",干净利落。
-- 推荐写法:先用较宽的向量召回拿候选,再在候选里应用业务过滤
WITH cand AS (
    SELECT id, content, embedding <=> :q AS dist
    FROM kb_chunk
    WHERE tenant_id = :tenant
    ORDER BY embedding <=> :q
    LIMIT 100
)
SELECT id, content, dist
FROM cand
WHERE created_at > now() - interval '30 days'
ORDER BY dist
LIMIT 10;

4.5 混合检索完整 SQL(向量 + 全文 + RRF 融合)

WITH vec AS (
    SELECT id,
           1.0 / (row_number() OVER (ORDER BY embedding <=> :q) + 60) AS score
    FROM kb_chunk
    WHERE tenant_id = :tenant
    ORDER BY embedding <=> :q
    LIMIT 50
),
fts AS (
    SELECT id,
           1.0 / (row_number() OVER (
               ORDER BY ts_rank(content_tsv, plainto_tsquery('zhcfg', :kw)) DESC
           ) + 60) AS score
    FROM kb_chunk
    WHERE tenant_id = :tenant
      AND content_tsv @@ plainto_tsquery('zhcfg', :kw)
    LIMIT 50
)
SELECT coalesce(v.id, f.id) AS id,
       coalesce(v.score, 0) + coalesce(f.score, 0) AS fused
FROM vec v
FULL OUTER JOIN fts f ON v.id = f.id
ORDER BY fused DESC
LIMIT 10;

RRF 的 1/(rank + k)(这里 k=60 是经验值)对两份排序做无权重融合,既不要求两个分数的量纲一致,也天然偏向"两边都很靠前"的文档。这是生产环境最稳的混合策略之一。

4.6 Python 端到端集成

下面是一段可直接运行的代码:用本地/云端模型生成 embedding,批量落库,再查询。以 psycopg 3 为例(它对 vector 类型有原生适配)。

import os
import psycopg
from openai import OpenAI  # 或任何 embedding 提供方

# 1) 连接(PG18 实例)
conn = psycopg.connect(
    "postgresql://app:secret@localhost:5432/kb",
    autocommit=False,
)

# 2) 生成 embedding(示例用 OpenAI,也可换 BGE/本地 onnx)
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def embed(texts: list[str]) -> list[list[float]]:
    resp = client.embeddings.create(
        model="text-embedding-3-small", input=texts
    )
    return [d.embedding for d in resp.data]

# 3) 批量插入(executemany 自动把 list 转成 vector 字面量)
rows = [
    (1, 1001, "服务半夜 OOM 的排查清单:先查 cgroup 内存上限..."),
    (1, 1002, "PostgreSQL 的 work_mem 设置过大导致排序撑爆内存..."),
    (1, 1003, "Kubernetes Pod 被 OOMKilled 的常见原因与 request/limit 配置..."),
]
with conn.cursor() as cur:
    vecs = embed([r[2] for r in rows])
    cur.executemany(
        "INSERT INTO kb_chunk (tenant_id, doc_id, content, embedding) "
        "VALUES (%s, %s, %s, %s)",
        [(r[0], r[1], r[2], v) for r, v in zip(rows, vecs)],
    )
conn.commit()

# 4) 查询:语义搜 "内存不够被系统干掉"
q = embed(["内存不够被系统杀掉"])[0]
with conn.cursor() as cur:
    cur.execute(
        "SELECT id, content FROM kb_chunk "
        "WHERE tenant_id = %s ORDER BY embedding <=> %s LIMIT 3",
        (1, q),
    )
    for id_, content in cur.fetchall():
        print(id_, "->", content[:40])

要点:psycopg 3 会把 Python list[float] 序列化为 '[...]' 字面量,无需手动拼字符串;批量插入用 executemany 而非循环单条,吞吐差一个数量级。

4.7 从 pgvector 迁移到 PG18 原生

由于存储字节兼容,迁移主要是"卸扩展、建内核索引":

-- 1) 老表若建在 pgvector 上,先确认无其它依赖
DROP EXTENSION IF EXISTS vector CASCADE;

-- 2) 用原生语法重建 HNSW(vector 列类型定义不变)
CREATE INDEX CONCURRENTLY kb_emb_hnsw ON kb_chunk
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 128);

CONCURRENTLY 避免锁表,线上可用。若原表用的是 halfvec/bit 量化类型,原生同样支持,零改写。


五、性能优化

5.1 HNSW 参数调优清单

目标调什么怎么调
更高召回ef_searchSET hnsw.ef_search = 200(查询期,无锁)
更高召回(构建期固化)ef_construction建索引 WITH (ef_construction = 256)
更密图/更高召回但更占内存mWITH (m = 32)
更快查询ef_search降到 40,配合放宽距离阈值再截断
更快构建并行 + 内存SET max_parallel_workers=8; SET maintenance_work_mem='2GB'

经验公式:召回率敏感场景 m=32, ef_construction=256, ef_search=200;延迟敏感且数据干净场景 m=16, ef_construction=128, ef_search=40

5.2 量化:用精度换内存

1536 维 vector 每行约 6KB。1000 万行就是 ~60GB 索引内存——很多机器扛不住。量化是标配:

  • halfvec(1536):float16,内存减半(~3KB/行),精度损失通常可忽略;
  • bit(1536):二值化(如 sign 量化),内存再降到 1/16(~192B/行),召回略降但极致省;
  • sparsevec:只存非零维,适合 TF-IDF 类稀疏向量。
ALTER TABLE kb_chunk ALTER COLUMN embedding TYPE halfvec(1536);
-- 重建索引时操作符类改为 vector_cosine_ops(halfvec 同样适用)

5.3 资源与并行

  • shared_buffers:让 HNSW 图的热点层常驻内存,减少随机 IO;
  • work_mem:控制排序/合并阶段的每算子内存,混合检索的 RRF 子查询受益;
  • effective_cache_size:告诉 planner 有多少 OS 缓存可用,影响它选 HNSW 还是顺序扫;
  • 大批量灌数据:先插数据、最后一次性建 HNSW,比边插边维护索引快数倍(HNSW 增量插入会缓慢劣化图质量)。

5.4 基准取舍:什么时候用 PG18 原生,什么时候上专用向量库

维度PG18 原生向量Milvus / Qdrant
数据规模千万级向量流畅亿级~十亿级更从容
混合查询SQL 内原生融合,强项需应用层拼接,弱
事务一致性与业务数据同事务独立,需双写对齐
运维一套 Postgres额外集群
极致召回/吞吐够用更强(专用优化)

决策线:如果你的向量和业务数据强耦合、需要做"带过滤/带关联"的检索、规模在千万级以内、且不想多养一套系统——PG18 原生是性价比之选。如果你的场景是纯向量、超大规模、对 QPS/召回有极致要求(如十亿级商品图搜),专用向量库仍不可替代。


六、总结与展望

PG18 把向量搜索做成数据库内核能力,本质上是把"AI 检索"从外围系统拉回事务边界内。它的价值不在 benchmark 上快那几个百分点,而在于:

  1. 架构收敛:一套 Postgres 同时存业务数据和向量,RAG 链路从"双库胶水"变成一条 SQL;
  2. 一致性免费:向量与订单/权限共享事务,不再担心"向量库有、主库没"的脏数据;
  3. 混合检索平民化:向量 + 全文 + 结构化过滤在一个查询计划里融合,RRF 就能拿到工业级效果;
  4. 迁移无痛:与 pgvector 字节兼容,老项目 DROP EXTENSION + 重建内核索引即可。

什么时候不该用:超十亿级纯向量、对单查询延迟有 P99 硬性 SLA、或需要 GPU 加速的极致召回——这些仍交给专用向量库。

展望 PG18 之后的演进:内核级 halfvec/bit 量化会更智能(自动按数据分布选量化策略)、HNSW 可能获得更好的增量维护与 ef_search 自动调优、RAG 框架(LangChain/LlamaIndex)也会把"PG18 原生向量"作为一等后端。对大多数中小团队而言,"业务库即向量库"的时代,已经从 2026 年 7 月正式开始了。


参考资料:PostgreSQL 18 官方发布说明、pgvector 项目文档、HNSW 原始论文(Malkov & Yashunin, 2018)、Reciprocal Rank Fusion(Cormack et al., 2009)。文中 SQL 均基于 PG18 原生向量语法,与 pgvector 兼容。

推荐文章

Graphene:一个无敌的 Python 库!
2024-11-19 04:32:49 +0800 CST
Nginx 负载均衡
2024-11-19 10:03:14 +0800 CST
LangChain快速上手
2025-03-09 22:30:10 +0800 CST
全栈工程师的技术栈
2024-11-19 10:13:20 +0800 CST
程序员茄子在线接单