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

07 · 向量检索落地

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


🎯 一句话

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

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


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

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

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

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

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

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

① 从 documents 表删掉这条记录
② 从向量库删掉它的所有向量

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

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

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


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

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

🗓️ 本章的 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;

注意 archiveddocuments 上、不在 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 模型 = 全部重算。 这件事的代价要提前知道:

实践建议在表里存一列 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 模型要全部重算」是一个典型的血缘问题 —— 那一章讲怎么系统地记录「这份数据是怎么来的」

✅ 检查点

  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,它走的就是同一条 post-filter 路,换个存储不会让近似索引变精确(🗓️ 较新版本有 hnsw.iterative_scan 这类开关,专门给它兜底——这恰恰说明问题真实存在)。⭐⭐ 它真正的优势是三条:① 小数据量可以干脆不建索引,精确扫描过滤得一条不差——「不建索引」不是专用库的选项;② 过滤条件能走普通 B-tree,「先过滤再精扫」还是「走向量索引」由查询规划器自己挑,不用你二选一;③ 过滤条件和向量在同一份数据、同一个事务里,archived 在另一张表上也能 JOIN,不必把元数据同步一份过去(同步就会漂)。
  7. 写入时算。 因为算 embedding 慢(几十秒起步),HTTP 请求等不了,要走队列(第 11 章);query 的 embedding 一次一条,快,可以同步算。⚠️ 两边必须用同一个模型 —— 用错不报错,只返回一堆不相关的东西。
  8. ⭐⭐ 全部重算:一百万 chunk 是一笔真金白银;重算期间新旧向量不能混在一张表(维度可能不同),要么停机要么建新表双写再切换。⭐ 提前存一列 embedding_model —— 不存的话,将来连「哪些需要重算」都查不出来。
  9. 任选三条:向量数超千万级QPS 高且延迟敏感需要 Postgres 没有的检索能力(GPU 加速、放不进内存的磁盘索引;⚠️ 量化压缩不算 —— pgvector 已经有 halfvec 和二值量化,拿它当理由是不成立的)、⭐ 向量和业务数据本来就分离(来自离线批处理、无一致性要求 —— 这时第一节的理由不成立)、团队已经在运维那个库。⚠️ 注意没有一条是「因为它是专门做这个的」 —— ⭐ 技术选型的成本不在「学会用」,在「长期运维」。

🛑 可以停在这里

走神救援

⭐⭐ 这一章的立场是:你的向量大概率应该和业务数据存在同一个 Postgres 里 —— 至少在百万条以内、至少在你能说清「为什么必须分开」之前。多一个专用库就多四样东西:一套备份恢复(还要和主库恢复到同一时间点)、一套监控、一套连接管理,以及 ⭐⭐ 最贵的一致性问题:用户删文档要删业务记录 + 删向量两步,它们不在一个事务里 —— ①成功②失败会导致 💀 文档已删除却仍能被检索到(数据泄露),反过来则是文档还在但问不出内容。⚠️ 网络抖动、进程重启、部署时机都会触发,要解决就得引入对账、软删除、事件溯源一整套本不需要的复杂度。⭐⭐ 而 pgvector 里这一切就是一行 ON DELETE CASCADE 用法上向量就是一种列类型VECTOR(1536),维度写死对应模型),查询是普通 SQL 加距离运算符:<=> 余弦(⭐ 大多数模型用它)、<-> L2、<#> 负内积;⚠️ 选错不报错只是悄悄检索更差,先查模型文档。索引 ⚠️ 别建太早:IVFFlat 建得快占空间小但 ⚠️⚠️ 必须先有数据才能建(靠数据聚类)—— 在空表上建完再灌一百万条,索引基于空数据、效果极差且不会报错,灌完要 REINDEX;HNSW 慢一点、占空间大,但可以在空表建、召回通常更高。⭐ 实践建议:几万条以内干脆不建索引(全表扫描又准又简单),真慢了先上 HNSW。⚠️ 索引是近似的,加了之后结果可能变,那是召回率问题要调 ef_search/probes,不是 bug。⭐⭐ 第四节是全章最关键的一节:真实需求几乎从不是「在全部向量里搜」,而是「在这个用户的、这个文件夹里的、没归档的文档里搜」——Postgres 里就是加个 WHEREarchiveddocuments 上就再 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

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