📑 本页目录(点开跳转)
07 · 向量检索落地
⏱ 48 分钟 | ⭐⭐ 先别急着上专用向量库
🎯 一句话
你的向量大概率应该和业务数据存在同一个 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 的优势到底是什么?是三条,都和「选项」有关:
| 优势 | 为什么专用库给不了 | |
|---|---|---|
| ① ⭐ | 小数据量可以干脆不建索引 —— 精确扫描,WHERE 和距离在同一遍扫描里算完,过滤结果一条不差 |
专用向量库存在的意义就是那个索引,「不建索引」不是它的选项 |
| ② | 过滤条件能用普通 B-tree 索引,于是「先按 user_id 过滤再精确算距离」和「走向量索引」是两条都可行的路,⭐ 查询规划器自己按选择性挑 |
你得自己在 pre / post 之间二选一,还得为每种查询手工判断 |
| ③ | 过滤条件和向量在同一份数据、同一个事务里 —— 所以上面那个 archived 在别的表上也能直接 JOIN |
专用库只能过滤自己那份元数据副本,archived 得同步一份过去,而同步就会漂(第一节那个问题的另一个化身) |
⭐⭐ 所以结论不变(多租户 + 业务过滤确实适合 pgvector),但理由要说对: 不是「这个问题不存在」,而是「你多了几个选项,而且不用自己选」。 尤其第 ① 条 —— 几万条数据时不建索引,你拿到的是精确解,这是专用库根本给不了的东西。
⚠️ 一个必须做对的安全细节:WHERE user_id = $2 这个条件绝不能是可选的。
第 8 章讲了怎么从结构上保证它不会被漏掉 —— 不是靠记得写。
🧮 五、embedding 在哪算、什么时候算
写入时算,不是查询时算。
上传文档 → 切分 → 【算 embedding】→ 连同文本一起入库
↑ 慢,几十秒起步,所以走队列(第 11 章)
用户提问 → 【算 query 的 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深水区.html | ⭐ 分工:那一章讲 RAG 策略(怎么切分、要不要重排、怎么评估检索质量),本章讲怎么真的存进库里。两章合起来才完整 |
| ⭐ 07b · 文件上传与对象存储 | ⚠️ 本章第五节那张流程图的第一步就是「上传文档」 —— 而这一步本章一个字没讲。那一章补上入口:为什么不能让文件流过你的后端、413 是怎么来的、预签名直传的形状、传完之后怎么触发切分 |
| ../推荐算法/10-向量检索与ANN.html | HNSW 和 IVF 为什么快、怎么工作在那一章。本章只讲该选哪个、什么时候建 |
| ../数据这一关/17-数据版本与血缘.html | 「换 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,它走的就是同一条 post-filter 路,换个存储不会让近似索引变精确(🗓️ 较新版本有
hnsw.iterative_scan这类开关,专门给它兜底——这恰恰说明问题真实存在)。⭐⭐ 它真正的优势是三条:① 小数据量可以干脆不建索引,精确扫描过滤得一条不差——「不建索引」不是专用库的选项;② 过滤条件能走普通 B-tree,「先过滤再精扫」还是「走向量索引」由查询规划器自己挑,不用你二选一;③ 过滤条件和向量在同一份数据、同一个事务里,archived在另一张表上也能 JOIN,不必把元数据同步一份过去(同步就会漂)。 - ⭐ 写入时算。 因为算 embedding 慢(几十秒起步),HTTP 请求等不了,要走队列(第 11 章);query 的 embedding 一次一条,快,可以同步算。⚠️ 两边必须用同一个模型 —— 用错不报错,只返回一堆不相关的东西。
- ⭐⭐ 全部重算:一百万 chunk 是一笔真金白银;重算期间新旧向量不能混在一张表(维度可能不同),要么停机要么建新表双写再切换。⭐ 提前存一列
embedding_model—— 不存的话,将来连「哪些需要重算」都查不出来。 - 任选三条:向量数超千万级、QPS 高且延迟敏感、需要 Postgres 没有的检索能力(GPU 加速、放不进内存的磁盘索引;⚠️ 量化压缩不算 —— pgvector 已经有
halfvec和二值量化,拿它当理由是不成立的)、⭐ 向量和业务数据本来就分离(来自离线批处理、无一致性要求 —— 这时第一节的理由不成立)、团队已经在运维那个库。⚠️ 注意没有一条是「因为它是专门做这个的」 —— ⭐ 技术选型的成本不在「学会用」,在「长期运维」。
🛑 可以停在这里
⚡ 走神救援
⭐⭐ 这一章的立场是:你的向量大概率应该和业务数据存在同一个 Postgres 里 —— 至少在百万条以内、至少在你能说清「为什么必须分开」之前。多一个专用库就多四样东西:一套备份恢复(还要和主库恢复到同一时间点)、一套监控、一套连接管理,以及 ⭐⭐ 最贵的一致性问题:用户删文档要删业务记录 + 删向量两步,它们不在一个事务里 —— ①成功②失败会导致 💀 文档已删除却仍能被检索到(数据泄露),反过来则是文档还在但问不出内容。⚠️ 网络抖动、进程重启、部署时机都会触发,要解决就得引入对账、软删除、事件溯源一整套本不需要的复杂度。⭐⭐ 而 pgvector 里这一切就是一行
ON DELETE CASCADE。 用法上向量就是一种列类型(VECTOR(1536),维度写死对应模型),查询是普通 SQL 加距离运算符:<=>余弦(⭐ 大多数模型用它)、<->L2、<#>负内积;⚠️ 选错不报错只是悄悄检索更差,先查模型文档。索引 ⚠️ 别建太早:IVFFlat 建得快占空间小但 ⚠️⚠️ 必须先有数据才能建(靠数据聚类)—— 在空表上建完再灌一百万条,索引基于空数据、效果极差且不会报错,灌完要 REINDEX;HNSW 慢一点、占空间大,但可以在空表建、召回通常更高。⭐ 实践建议:几万条以内干脆不建索引(全表扫描又准又简单),真慢了先上 HNSW。⚠️ 索引是近似的,加了之后结果可能变,那是召回率问题要调ef_search/probes,不是 bug。⭐⭐ 第四节是全章最关键的一节:真实需求几乎从不是「在全部向量里搜」,而是「在这个用户的、这个文件夹里的、没归档的文档里搜」——Postgres 里就是加个WHERE,archived在documents上就再JOIN一次(专用库做不到这件事,它只能过滤自己那份元数据副本,而副本要同步、同步就会漂)。「带元数据过滤的检索」只有两种做法:pre-filter 会让候选集变小、向量索引失效退化成暴力扫,post-filter 会出现 ⚠️ 取回 top 100 过滤完只剩 3 条的情况。⚠️⚠️ 别以为 pgvector 没有这个困境 —— 只要按第三节建了 HNSW / IVFFlat,它走的就是同一条 post-filter 路,换个存储不会让近似索引变成精确索引(🗓️ 较新版本专门给了hnsw.iterative_scan这类开关兜底,这恰恰说明问题真实存在)。⭐⭐ 它真正的优势是三条:① 小数据量可以干脆不建索引,精确扫描过滤得一条不差 —— 「不建索引」不是专用库的选项;② 过滤条件能走普通 B-tree,「先过滤再精扫」还是「走向量索引」由查询规划器自己挑,不用你手工二选一;③ 过滤条件和向量在同一份数据、同一个事务里。所以多租户场景确实适合 pgvector,但理由是「你多了几个选项,而且不用自己选」,不是「问题不存在」。⚠️ 那个WHERE user_id绝不能是可选的,第 8 章讲怎么从结构上保证。embedding 写入时算(慢,几十秒起步,走队列),query 的查询时算(快);⚠️ 两边必须同一个模型,用错不报错只返回不相关内容。⭐⭐ 换模型 = 全部重算,一百万 chunk 是真金白银,新旧维度可能不同不能混表,要么停机要么双写切换 —— ⭐ 提前存一列embedding_model,否则将来连「哪些要重算」都查不出来。真该上专用库的信号:向量超千万、QPS 高且延迟敏感、需要 Postgres 没有的检索能力(GPU、磁盘索引;⚠️ 量化压缩不算,pgvector 已经有)、⭐ 向量本来就和业务数据分离(来自离线批处理、无一致性要求)、团队已在运维它。⚠️ 没有一条是「因为它是专门做这个的」 —— ⭐ 技术选型的成本不在学会用,在长期运维:多一个有状态组件,就多一个会在凌晨出问题的东西。
下一节 👉 07b-文件上传与对象存储.md