🏠 总目录📚 本教程 07 · 向量检索落地 ← →
📑 本页目录(点开跳转)

07 · 向量检索落地

⏱ 42 分钟 | ⭐ 先别急着上专用向量库


🎯 一句话

你的向量大概率应该和业务数据存在同一个 Postgres 里 —— 至少在百万条以内、至少在你能说清「为什么必须分开」之前。

⚠️ 这一章不讲 RAG 该怎么切分、怎么重排(那是《大模型全景导论》08), 也不讲 HNSW 和 IVF 的原理(那是《推荐算法》10)。 ⭐ 本章只讲一件事:怎么把它真的存进库里、和业务数据一起查。


🤔 一、先回答那个所有人都跳过的问题

搜「向量数据库」,出来的全是专用产品的对比评测。几乎没人先问一句:你需要多一个数据库吗?

多一个数据库,你就多了:

多出来的 具体是什么
一套备份和恢复 而且要和主库恢复到同一个时间点,否则数据对不上
一套监控告警 又一个会半夜挂掉的东西
一套连接管理 连接池、超时、重试,全部再来一遍
⭐ 一致性问题 这条最贵,下面单独说

⭐ 决定性的那个问题:向量和业务数据要不要保持一致

假设用户删了一份文档。你要:

操作步骤

  1. 从 documents 表删掉这条记录
  2. 从向量库删掉它的所有向量

这两步不在一个事务里。 于是:

⚠️ 这不是理论风险,是必然会发生的 —— 网络抖动、进程重启、部署时机,任何一个都能制造它。 要解决就得引入对账任务、软删除、事件溯源……一整套本来不需要的复杂度。

⭐ 而如果向量就在同一个 Postgres 里,上面整个问题不存在 —— 一个 DELETE ... CASCADE,一个事务,完事。


🐘 二、pgvector:把向量当成一个字段

pgvector 是 Postgres 的一个扩展。装上之后,向量就是一种列类型,和 int、text 平级。

🗓️ 本章的 SQL 全部未实跑 —— 需要 PostgreSQL + pgvector 扩展,本地没有环境就跑不了。 而且运算符、索引参数、支持的向量类型都随 pgvector 版本变,动手前查你那一版的文档。

-- 🗓️ 未实跑,需要 PostgreSQL + pgvector
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (                  -- ⭐ 业务侧的文档表,chunks 挂在它下面
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT  NOT NULL,
    title       TEXT    NOT NULL,
    archived    BOOLEAN NOT NULL DEFAULT false,   -- ⭐ 第四节要拿它过滤
    created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE chunks (
    id          BIGSERIAL PRIMARY KEY,
    document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    user_id     BIGINT NOT NULL,          -- ⭐ 多租户过滤要用,见第四节
    content     TEXT   NOT NULL,
    embedding   VECTOR(1536),             -- ⭐ 维度写死,和你的 embedding 模型对应
    created_at  TIMESTAMPTZ DEFAULT now()
);

⭐ 注意 ON DELETE CASCADE —— 上一节那个一致性问题,在这里就是一行 DDL 解决的。 删文档,它的所有 chunk 和向量跟着走,在同一个事务里。

查询就是普通 SQL 加一个距离运算符:

-- 🗓️ 未实跑,需要 PostgreSQL + pgvector
SELECT content, embedding <=> $1 AS distance
FROM chunks
ORDER BY embedding <=> $1
LIMIT 5;

三个距离运算符,⚠️ 必须和你的 embedding 模型匹配:

运算符 距离 什么时候用
<=> 余弦距离 ⭐ 大多数 embedding 模型用这个(OpenAI、BGE 等)
<-> L2(欧氏) 模型文档明确说用 L2 时
<#> 负内积 向量已归一化时,比余弦快一点

⚠️ 用错了不会报错,只会悄悄检索得更差 —— 这是最难发现的一类 bug。先去查你的模型文档。


📇 三、索引:⚠️ 别建太早

不建索引,Postgres 会全表扫描算距离。几千条时这完全没问题(几十毫秒), 几十万条就开始难受了。

pgvector 有两种索引:

IVFFlat HNSW
建索引速度 快 ⚠️ 慢很多
占用空间 小 大
查询速度 好 ⭐ 更好
召回率 依赖参数调优 ⭐ 通常更高
⚠️ 致命点 必须先有数据才能建(要靠数据聚类) 可以在空表上建

⚠️⚠️ IVFFlat 那个「必须先有数据」是新手最容易栽的地方: 你在空表上建了 IVFFlat 索引,之后灌进去一百万条 —— 索引是基于那时候的空数据建的,效果极差, 而且不会有任何报错。灌完数据要 REINDEX。

⭐ 实践建议: 数据量小(几万以内)时,干脆先不建索引 —— 全表扫描又准又简单,还省得调参。 等真的慢了,先上 HNSW(不用操心数据分布),除非内存吃紧再考虑 IVFFlat。

⚠️ 索引是近似的。建了索引,检索结果可能和精确搜索不一样 —— 这是用速度换准确度,不是 bug。如果你发现「加了索引之后效果变差」,那是召回率在下降, 要调参数(HNSW 的 ef_search、IVFFlat 的 probes),不是索引坏了。


🔒 四、⭐ 过滤 + 向量检索:差别到底在哪(⚠️ 最容易被讲错的一节)

真实需求几乎从来不是「在全部向量里搜最相似的」,而是:

「在这个用户的、这个文件夹里的、没被归档的文档里,搜最相似的 5 条」

在 Postgres 里,这就是加个 WHERE:

-- 🗓️ 未实跑,需要 PostgreSQL + pgvector
SELECT c.content, c.embedding <=> $1 AS distance
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE c.user_id = $2          -- ⭐ 租户隔离
  AND d.archived = false      -- ⭐ 注意:这个条件在另一张表上
ORDER BY c.embedding <=> $1
LIMIT 5;

⭐ 注意 archived 在 documents 上、不在 chunks 上 —— 关系库直接 JOIN 过去就行, 这一点下面还会回来说。

在专用向量库里,这件事叫「带元数据过滤的检索」,是一个需要专门支持的特性, 各家实现差异很大,通常就两种做法,各有各的问题:

做法 问题
先过滤再搜(pre-filter) 过滤后候选集变小,向量索引可能失效,退化成暴力扫
先搜再过滤(post-filter) ⚠️ 取回 top 100 过滤完只剩 3 条,甚至一条不剩 —— 你要的 5 条拿不到

⚠️⚠️ 别搞错:这个困境 pgvector 也有

很多「pgvector vs 专用向量库」的对比文章会把这个困境说成专用库独有。这是错的。

一旦你按上一节的建议建了 HNSW / IVFFlat,pgvector 走的就是同一条 post-filter 路: 近似索引先按距离取回一批候选,WHERE 再在这批候选上过滤 —— 同样会出现「取回一批、过滤完只剩几条」。换个存储不会让近似索引变成精确索引。

🗓️ 较新的 pgvector 提供了迭代索引扫描(hnsw.iterative_scan 之类的开关), 过滤完不够数就自动往下再扫一段。⭐ 这恰恰说明这个问题在 pgvector 里真实存在 —— 不然不会专门给它一个开关。版本相关,用前查你那版文档。

pgvector 的价值在于与业务 SQL、事务和查询规划器的结合,不能把专用向量库说成“不支持精确搜索”。例如 Qdrant 的 SearchParams(exact=True) 就能提供精确对照。官方 ANN 召回实验。

比较项 pgvector 的特点 对照时须核实
精确与近似 不建近似索引时可精确扫描;也可建 HNSW/IVFFlat 专用库也可能支持精确模式;按具体版本、过滤与数据规模测试
过滤与规划 B-tree、SQL 条件与向量查询可组合 规划器不保证总选最优;用 EXPLAIN 与实测观察,不能只看 SQL 写法
事务和关联 与业务数据在同一个 PostgreSQL 事务中更新,可 JOIN 独立向量库与业务库之间通常需要同步;更新/删除/撤权必须量延迟并对账

小数据量先用精确搜索建立基线;大数据量再测近似索引的召回—延迟取舍。存储选型要比较自己的过滤分布、成本和运维条件。检索实验将这种对照接到 RAG。

⚠️ 一个必须做对的安全细节:WHERE user_id = $2 这个条件绝不能是可选的。 第 8 章讲了怎么从结构上保证它不会被漏掉 —— 不是靠记得写。


🧮 五、embedding 在哪算、什么时候算

写入时算,不是查询时算。

因果链

上传文档→切分→【算 embedding】→连同文本一起入库
↑ 慢,几十秒起步,所以走队列(第 11 章)
用户提问→【算 query 的 embedding】→查询
↑ 快,一次一条,可以同步做

⚠️ 两边必须用同一个模型。用 A 模型存的向量、用 B 模型的 query 去搜, 不会报错,只会返回一堆不相关的东西。

⭐ 换 embedding 模型 = 全部重算。 这件事的代价要提前知道:

⭐ 实践建议:在表里存一列 embedding_model,记下这条向量是哪个模型算的。 不存的话,将来你连「哪些需要重算」都查不出来。


🚦 六、什么时候真该上专用向量库

前面全在劝你别上。但确实存在该上的时候 —— 判断信号:

信号 说明
⭐ 向量数超过千万级 pgvector 的索引构建和内存占用开始成为运维负担
QPS 很高且延迟敏感 专用库在纯向量检索这一件事上做了更多优化
需要 Postgres 没有的检索能力 比如 GPU 加速检索、放不进内存的磁盘索引。🗓️ ⚠️ 量化压缩不在此列 —— pgvector 已经支持标量量化(halfvec)和二值量化,别拿一个不成立的理由去多养一个数据库(版本相关,用前查文档)
向量和业务数据本来就分离 ⭐ 比如向量来自离线批处理,和在线库没有一致性要求 —— 这时第一节那个理由不成立了
团队已经在运维那个库了 边际成本为零,那就用

⚠️ 注意上面没有一条是「因为它是专门做这个的」。 ⭐ 技术选型的成本不在「学会用」,在「长期运维」 —— 多一个有状态的组件,就多一个会在凌晨出问题的东西。


🔄 七、换个栈怎么对应

Python(本章主栈) Node Go
驱动 psycopg / asyncpg pg / postgres.js pgx
向量类型 pgvector 官方客户端 🗓️ 同 同
ORM 集成 SQLAlchemy + pgvector Drizzle / Prisma(⚠️ 向量支持较新,先查) sqlc / GORM

⭐ 核心概念三家完全一致 —— 建表、<=> 运算符、索引、过滤,都是 SQL 层的事。 ⭐ 这一章的内容几乎全部是 SQL 和数据建模,换语言不影响 —— 这也是选 pgvector 的一个附带好处:你的知识不绑定在某个 SDK 上。

(🗓️ 上表的包名和各家 ORM 的向量支持程度都在变,用前先查。 ⚠️ 官方客户端的仓库名带语言后缀(pgvector-python / -node / -go),但包管理器里的安装名不一定带 —— 以 PyPI / npm / Go module 上的实际名字为准,别照着仓库名去装。)


🔗 这一章连到哪里

去哪 为什么
06 · 关系数据库 ⭐ 本章的表就建在那一章那个库里。连接池、迁移、事务边界那几条在这里同样适用
08 · 认证、会话与多租户 ⭐ 第四节那个 WHERE user_id 绝不能漏。那一章讲怎么从结构上保证 —— 不是靠记得写
11 · 长任务与队列 算 embedding 是几十秒的活,HTTP 请求等不了 —— 必须走队列
大模型全景导论 08 · RAG 深水区 ⭐ 分工:那一章讲 RAG 策略(怎么切分、要不要重排、怎么评估检索质量),本章讲怎么真的存进库里。两章合起来才完整
⭐ 07b · 文件上传与对象存储 ⚠️ 本章第五节那张流程图的第一步就是「上传文档」 —— 而这一步本章一个字没讲。那一章补上入口:为什么不能让文件流过你的后端、413 是怎么来的、预签名直传的形状、传完之后怎么触发切分
推荐算法 10 · 向量检索 ANN HNSW 和 IVF 为什么快、怎么工作在那一章。本章只讲该选哪个、什么时候建
数据这一关 17 · 版本与血缘 「换 embedding 模型要全部重算」是一个典型的血缘问题 —— 那一章讲怎么系统地记录「这份数据是怎么来的」

✅ 检查点

  1. 多一个专用向量库,会多出哪四样东西?哪一样最贵?
  2. 用户删除文档时,向量和业务数据分开存会出什么问题?pgvector 怎么绕开它?
  3. pgvector 的三个距离运算符分别是什么?选错了会怎样?
  4. IVFFlat 有一个新手必栽的坑,是什么?为什么特别难发现?
  5. 数据量只有几万条时,索引该怎么建?
  6. ⭐ 「带元数据过滤的检索」有哪两种做法、各有什么问题?⚠️ 建了 HNSW 之后 pgvector 还有这个问题吗?那它的优势到底是哪三条?
  7. embedding 该在写入时算还是查询时算?为什么?
  8. 换 embedding 模型的代价是什么?该提前存哪一列来降低这个代价?
  9. 什么时候真该上专用向量库?列三条信号。
👀 答案
  1. 一套备份恢复(还要和主库恢复到同一时间点)、一套监控告警、一套连接管理、一致性问题(最贵)。
  2. 删除要做两步(删业务记录、删向量),这两步不在一个事务里:①成功②失败 → 💀 文档已删除但仍能被检索到(数据泄露);①失败②成功 → 文档还在但问不出内容。⚠️ 网络抖动、进程重启、部署时机都会触发。pgvector 里一行 ON DELETE CASCADE 就解决了 —— 同一个事务。
  3. <=> 余弦距离(大多数模型用这个)、<-> L2 欧氏、<#> 负内积(向量已归一化时更快)。⚠️ 选错不会报错,只会悄悄检索得更差 —— 最难发现的一类 bug。先查模型文档。
  4. ⚠️⚠️ IVFFlat 必须先有数据才能建(要靠数据聚类)。在空表上建完再灌一百万条,索引是基于空数据建的、效果极差,而且不会有任何报错。灌完要 REINDEX。
  5. 干脆先不建。 几万条全表扫描又准又简单,还省得调参。真慢了先上 HNSW(不用操心数据分布),除非内存吃紧再考虑 IVFFlat。⚠️ 另外记住索引是近似的:加索引后结果可能和精确搜索不同,那是召回率下降,要调 ef_search/probes,不是 bug。
  6. 两种做法:pre-filter(先过滤再搜——候选集变小,向量索引可能失效退化成暴力扫)、post-filter(先搜再过滤——⚠️ 取回 top 100 过滤完只剩 3 条甚至 0 条,要的 5 条拿不到)。⚠️⚠️ pgvector 一样有这个问题:实际使用 HNSW / IVFFlat 的查询可能遇到过滤后不足 K,应检查执行计划与迭代扫描配置,换个存储不会让近似索引变精确(🗓️ 较新版本有 hnsw.iterative_scan 这类开关,专门给它兜底——这恰恰说明问题真实存在)。它真正的优势是三条:① 小数据量可以干脆不建索引,精确扫描过滤得一条不差——专用库也可能提供精确模式,需按具体引擎验证;② 过滤条件能走普通 B-tree,「先过滤再精扫」还是「走向量索引」由查询规划器自己挑,不用你二选一;③ 过滤条件和向量在同一份数据、同一个事务里,archived 在另一张表上也能 JOIN,不必把元数据同步一份过去(同步就会漂)。
  7. 写入时算。 因为算 embedding 慢(几十秒起步),HTTP 请求等不了,要走队列(第 11 章);query 的 embedding 一次一条,快,可以同步算。⚠️ 两边必须用同一个模型 —— 用错不报错,只返回一堆不相关的东西。
  8. 全部重算:一百万 chunk 是一笔真金白银;重算期间新旧向量不能混在一张表(维度可能不同),要么停机要么建新表双写再切换。提前存一列 embedding_model —— 不存的话,将来连「哪些需要重算」都查不出来。
  9. 任选三条:向量数超千万级、QPS 高且延迟敏感、需要 Postgres 没有的检索能力(GPU 加速、放不进内存的磁盘索引;⚠️ 量化压缩不算 —— pgvector 已经有 halfvec 和二值量化,拿它当理由是不成立的)、向量和业务数据本来就分离(来自离线批处理、无一致性要求 —— 这时第一节的理由不成立)、团队已经在运维那个库。⚠️ 注意没有一条是「因为它是专门做这个的」 —— 技术选型的成本不在「学会用」,在「长期运维」。

🛑 可以停在这里

⚡ 走神救援

⭐ 这一章的立场:你的向量大概率应该和业务数据存在同一个数据库里——至少在百万条以内,至少在你能说清「为什么必须分开」之前。

⭐ 多一个专用库就多四样东西:一套备份恢复、一套监控、一套连接管理,以及最贵的那个——一致性。删文档要删业务记录和删向量两步,⚠️ 它们不在一个事务里。💀 一半成功就是「文档已删除却仍能被检索到」,那是数据泄露;反过来是文档还在但问不出内容。⭐ 而放在一起时,这一切就是一行级联删除。

⚠️ 索引别建太早:那类近似索引必须先有数据才能建(它靠数据聚类)——在空表上建完再灌数据,索引基于空数据、效果极差,而且不会报错。 ⭐ 几万条以内干脆不建索引,全表扫描又准又简单。

⭐⭐ 最关键的一节是「带元数据过滤的检索」:真实需求几乎从不是「在全部向量里搜」,而是「在这个用户的、这个文件夹里的、没归档的文档里搜」。⚠️ 别以为放在一起就没有先过滤还是后过滤的困境——⭐ 只要建了近似索引,走的就是同一条路;换个存储不会让近似索引变成精确索引。

它真正的优势是三条:小数据量可以干脆不建索引(专用库也可能支持精确模式)、过滤条件走普通索引由查询规划器自己挑、过滤和向量在同一份数据同一个事务里。⭐ 所以理由是「你多了几个选项而且不用自己选」,不是「问题不存在」。

⭐ 换 embedding 模型等于全部重算,新旧维度可能不同、不能混在一张表里——⭐ 提前存一列「用的是哪个模型」,否则将来连「哪些要重算」都查不出来。

⚠️ 真该上专用库的信号里没有一条是「因为它是专门做这个的」——⭐ 技术选型的成本不在学会用,在长期运维:多一个有状态组件,就多一个会在凌晨出问题的东西。

下一节 👉 07b-文件上传与对象存储.md

打卡记录保存在你的浏览器里,首页能看到总进度