📑 本页目录(点开跳转)
07 · 向量检索落地
⏱ 42 分钟 | ⭐ 先别急着上专用向量库
🎯 一句话
你的向量大概率应该和业务数据存在同一个 Postgres 里 —— 至少在百万条以内、至少在你能说清「为什么必须分开」之前。
⚠️ 这一章不讲 RAG 该怎么切分、怎么重排(那是《大模型全景导论》08), 也不讲 HNSW 和 IVF 的原理(那是《推荐算法》10)。 ⭐ 本章只讲一件事:怎么把它真的存进库里、和业务数据一起查。
🤔 一、先回答那个所有人都跳过的问题
搜「向量数据库」,出来的全是专用产品的对比评测。几乎没人先问一句:你需要多一个数据库吗?
多一个数据库,你就多了:
| 多出来的 | 具体是什么 |
|---|---|
| 一套备份和恢复 | 而且要和主库恢复到同一个时间点,否则数据对不上 |
| 一套监控告警 | 又一个会半夜挂掉的东西 |
| 一套连接管理 | 连接池、超时、重试,全部再来一遍 |
| ⭐ 一致性问题 | 这条最贵,下面单独说 |
⭐ 决定性的那个问题:向量和业务数据要不要保持一致
假设用户删了一份文档。你要:
操作步骤
- 从 documents 表删掉这条记录
- 从向量库删掉它的所有向量
这两步不在一个事务里。 于是:
- ① 成功 ② 失败 → 💀 文档已删除,但用户提问时还能检索到它的内容(数据泄露)
- ① 失败 ② 成功 → 文档还在列表里,但问不出任何内容(用户困惑)
⚠️ 这不是理论风险,是必然会发生的 —— 网络抖动、进程重启、部署时机,任何一个都能制造它。 要解决就得引入对账任务、软删除、事件溯源……一整套本来不需要的复杂度。
⭐ 而如果向量就在同一个 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 在哪算、什么时候算
写入时算,不是查询时算。
因果链
⚠️ 两边必须用同一个模型。用 A 模型存的向量、用 B 模型的 query 去搜, 不会报错,只会返回一堆不相关的东西。
⭐ 换 embedding 模型 = 全部重算。 这件事的代价要提前知道:
- 一百万个 chunk,按常见的 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 模型要全部重算」是一个典型的血缘问题 —— 那一章讲怎么系统地记录「这份数据是怎么来的」 |
✅ 检查点
- 多一个专用向量库,会多出哪四样东西?哪一样最贵?
- 用户删除文档时,向量和业务数据分开存会出什么问题?pgvector 怎么绕开它?
- pgvector 的三个距离运算符分别是什么?选错了会怎样?
- IVFFlat 有一个新手必栽的坑,是什么?为什么特别难发现?
- 数据量只有几万条时,索引该怎么建?
- ⭐ 「带元数据过滤的检索」有哪两种做法、各有什么问题?⚠️ 建了 HNSW 之后 pgvector 还有这个问题吗?那它的优势到底是哪三条?
- embedding 该在写入时算还是查询时算?为什么?
- 换 embedding 模型的代价是什么?该提前存哪一列来降低这个代价?
- 什么时候真该上专用向量库?列三条信号。
👀 答案
- 一套备份恢复(还要和主库恢复到同一时间点)、一套监控告警、一套连接管理、一致性问题(最贵)。
- 删除要做两步(删业务记录、删向量),这两步不在一个事务里:①成功②失败 → 💀 文档已删除但仍能被检索到(数据泄露);①失败②成功 → 文档还在但问不出内容。⚠️ 网络抖动、进程重启、部署时机都会触发。pgvector 里一行
ON DELETE CASCADE就解决了 —— 同一个事务。 <=>余弦距离(大多数模型用这个)、<->L2 欧氏、<#>负内积(向量已归一化时更快)。⚠️ 选错不会报错,只会悄悄检索得更差 —— 最难发现的一类 bug。先查模型文档。- ⚠️⚠️ IVFFlat 必须先有数据才能建(要靠数据聚类)。在空表上建完再灌一百万条,索引是基于空数据建的、效果极差,而且不会有任何报错。灌完要
REINDEX。 - 干脆先不建。 几万条全表扫描又准又简单,还省得调参。真慢了先上 HNSW(不用操心数据分布),除非内存吃紧再考虑 IVFFlat。⚠️ 另外记住索引是近似的:加索引后结果可能和精确搜索不同,那是召回率下降,要调
ef_search/probes,不是 bug。 - 两种做法:pre-filter(先过滤再搜——候选集变小,向量索引可能失效退化成暴力扫)、post-filter(先搜再过滤——⚠️ 取回 top 100 过滤完只剩 3 条甚至 0 条,要的 5 条拿不到)。⚠️⚠️ pgvector 一样有这个问题:实际使用 HNSW / IVFFlat 的查询可能遇到过滤后不足 K,应检查执行计划与迭代扫描配置,换个存储不会让近似索引变精确(🗓️ 较新版本有
hnsw.iterative_scan这类开关,专门给它兜底——这恰恰说明问题真实存在)。它真正的优势是三条:① 小数据量可以干脆不建索引,精确扫描过滤得一条不差——专用库也可能提供精确模式,需按具体引擎验证;② 过滤条件能走普通 B-tree,「先过滤再精扫」还是「走向量索引」由查询规划器自己挑,不用你二选一;③ 过滤条件和向量在同一份数据、同一个事务里,archived在另一张表上也能 JOIN,不必把元数据同步一份过去(同步就会漂)。 - 写入时算。 因为算 embedding 慢(几十秒起步),HTTP 请求等不了,要走队列(第 11 章);query 的 embedding 一次一条,快,可以同步算。⚠️ 两边必须用同一个模型 —— 用错不报错,只返回一堆不相关的东西。
- 全部重算:一百万 chunk 是一笔真金白银;重算期间新旧向量不能混在一张表(维度可能不同),要么停机要么建新表双写再切换。提前存一列
embedding_model—— 不存的话,将来连「哪些需要重算」都查不出来。 - 任选三条:向量数超千万级、QPS 高且延迟敏感、需要 Postgres 没有的检索能力(GPU 加速、放不进内存的磁盘索引;⚠️ 量化压缩不算 —— pgvector 已经有
halfvec和二值量化,拿它当理由是不成立的)、向量和业务数据本来就分离(来自离线批处理、无一致性要求 —— 这时第一节的理由不成立)、团队已经在运维那个库。⚠️ 注意没有一条是「因为它是专门做这个的」 —— 技术选型的成本不在「学会用」,在「长期运维」。
🛑 可以停在这里
⚡ 走神救援
⭐ 这一章的立场:你的向量大概率应该和业务数据存在同一个数据库里——至少在百万条以内,至少在你能说清「为什么必须分开」之前。
⭐ 多一个专用库就多四样东西:一套备份恢复、一套监控、一套连接管理,以及最贵的那个——一致性。删文档要删业务记录和删向量两步,⚠️ 它们不在一个事务里。💀 一半成功就是「文档已删除却仍能被检索到」,那是数据泄露;反过来是文档还在但问不出内容。⭐ 而放在一起时,这一切就是一行级联删除。
⚠️ 索引别建太早:那类近似索引必须先有数据才能建(它靠数据聚类)——在空表上建完再灌数据,索引基于空数据、效果极差,而且不会报错。 ⭐ 几万条以内干脆不建索引,全表扫描又准又简单。
⭐⭐ 最关键的一节是「带元数据过滤的检索」:真实需求几乎从不是「在全部向量里搜」,而是「在这个用户的、这个文件夹里的、没归档的文档里搜」。⚠️ 别以为放在一起就没有先过滤还是后过滤的困境——⭐ 只要建了近似索引,走的就是同一条路;换个存储不会让近似索引变成精确索引。
它真正的优势是三条:小数据量可以干脆不建索引(专用库也可能支持精确模式)、过滤条件走普通索引由查询规划器自己挑、过滤和向量在同一份数据同一个事务里。⭐ 所以理由是「你多了几个选项而且不用自己选」,不是「问题不存在」。
⭐ 换 embedding 模型等于全部重算,新旧维度可能不同、不能混在一张表里——⭐ 提前存一列「用的是哪个模型」,否则将来连「哪些要重算」都查不出来。
⚠️ 真该上专用库的信号里没有一条是「因为它是专门做这个的」——⭐ 技术选型的成本不在学会用,在长期运维:多一个有状态组件,就多一个会在凌晨出问题的东西。
下一节 👉 07b-文件上传与对象存储.md