向量数据库
向量数据库
一、向量数据库是什么
向量数据库不是“能存向量的数据库”,而是“在可接受召回损失下,把 top-k 近邻查询从全表扫描变成索引查找”的系统。它用少量召回换取数量级的延迟下降。选型先看数据规模、过滤选择性和运维能力,再看 benchmark;十万级与十亿级根本不是同一个问题。
1.1 核心概念
给定一条查询向量,向量数据库要在海量向量里找出距离最近的若干条。精确做法是逐条算距离,复杂度与数据量成正比;数据到百万级,单次查询就要读数 GB、做上亿次乘加,QPS 一高就撑不住。近似最近邻(ANN)索引只访问“可能相关”的一小部分,把查询压到毫秒级,代价是可能漏掉真正的最近邻。
这套取舍由四个量共同决定:召回率、延迟、内存、构建成本。调任何一个参数,通常都在移动这四者之间的平衡点。工程上要先把业务能接受的最低召回定下来,再找满足它的最省资源组合。
这四个量无法同时最优。要提高召回,就得扩大候选范围,延迟和内存随之上升;要压延迟,就得减少访问节点,召回下降;要省内存,就得量化或降维,精度受损。所谓调优,是在业务约束下找到一个可解释的平衡点,而不是把某个指标推到极限。
| 概念 | 含义 | 工程影响 |
|---|---|---|
| 距离度量 | 余弦、内积、欧氏距离 | 必须与索引算子类匹配 |
| ANN | 近似最近邻搜索 | 召回率小于 100%,需评测 |
| 过滤选择性 | 条件筛掉多少数据 | 决定前过滤还是后过滤 |
| 索引内存 | 向量加图结构占用 | 不足时查询落盘,延迟骤升 |
| 段与压实 | 写入产生的临时结构 | 影响删除可见性与磁盘占用 |
1.2 与相邻技术的关系
向量数据库常被误解为“带向量字段的数据库”。两者的差别不在存储,而在索引与查询执行:普通数据库即使能存向量,没有 ANN 索引,查询仍是全表扫描。
| 技术 | 核心能力 | 适用 | 边界 |
|---|---|---|---|
| 关系数据库 | 事务、约束、join | 业务主数据、权限 | 大规模向量检索吃力 |
| 向量数据库 | ANN 检索、过滤 | 语义召回、推荐 | 不做复杂事务与分析 |
| 搜索引擎 | 倒排索引、BM25、聚合 | 关键词与混合检索 | 向量能力因产品而异 |
| FAISS | 向量索引库 | 离线批处理、研究 | 无服务端、无权限与并发管理 |
| 特征平台 | 特征生产与版本 | 推荐、排序 | 不直接提供在线近邻查询 |
选型时要区分“库”和“服务”。FAISS 是库,索引的持久化、并发、权限都要自己写;pgvector 是 PostgreSQL 扩展,检索和业务表在同一个事务里;Milvus、Qdrant 是独立服务,扩缩容和过滤能力更强,但多一个组件要运维。
向量数据库也不替代业务库。用户、订单、权限仍在关系库,向量库存 chunk_id、向量和少量过滤字段,原文可以放对象存储。把两者混成一张大表,备份、迁移和权限审计都会变重。
1.3 架构组成
一条查询进入系统后,先由规划器判断过滤条件的选择性,决定先过滤还是先扫索引;索引返回候选,系统再用精确距离重算并归并;最后按 top_n 返回。
写入路径与查询路径分开:写入先落 WAL 和内存缓冲,再异步构建或更新索引。这个设计带来两个直接后果:刚写入的数据可能短暂查不到;删除通常是标记删除,空间不会立刻释放。理解这两点,能解释大部分“刚更新却没生效”的问题。
二、安装与最小示例
2.1 环境与镜像
版本现状(2026):pgvector 0.8 系列支持 HNSW 迭代扫描,缓解高选择性过滤下候选不足的问题。镜像标签与扩展版本以仓库当前列表为准,下面用 PostgreSQL 17 的镜像做演示。
# 启动带 pgvector 扩展的 PostgreSQL;密码仅用于本地演示
docker run -d --name pgv \
-e POSTGRES_PASSWORD='change-me' \
-p 5432:5432 \
-v pgv-data:/var/lib/postgresql/data \
pgvector/pgvector:pg17
# 等待日志出现 database system is ready to accept connections
docker logs -f pgv# Python 客户端:psycopg 3 与 pgvector 类型适配器
pip install -U "psycopg[binary]" pgvector numpy| 组件 | 作用 | 检查方式 |
|---|---|---|
| PostgreSQL | 存储、事务、SQL 过滤 | psql 能连接 |
| pgvector | vector 类型与 ANN 索引 | \dx 能看到 vector |
| 数据卷 | 容器重启后保留数据 | docker volume ls |
| 客户端 | psycopg 或 psql | 版本与驱动匹配 |
2.2 建表与导入
-- 扩展只需安装一次,之后所有会话可用
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
tenant text NOT NULL,
section text,
content text NOT NULL,
embedding vector(1536) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 过滤字段先建普通索引,向量索引等数据灌完再建
CREATE INDEX chunks_tenant ON chunks (tenant, updated_at DESC);导入用参数化写入,向量以数组交给驱动适配,不手拼 SQL:
import psycopg
from pgvector.psycopg import register_vector
conn = psycopg.connect(
"postgresql://postgres:change-me@localhost:5432/postgres"
)
register_vector(conn) # 让 numpy 数组与 vector 类型自动转换
rows = [
# vec1、vec2 为上游嵌入模型的输出,维度必须与表定义一致
("refund#3.2", "team-a", "3.2", "退款审核通过后 3~5 个工作日到账。", vec1),
("invoice#12", "team-a", "12", "发票需在订单完成后 30 天内申请。", vec2),
]
with conn.cursor() as cur:
cur.executemany(
"""INSERT INTO chunks (doc_id, tenant, section, content, embedding)
VALUES (%s, %s, %s, %s, %s)""",
rows,
)
conn.commit()批量导入完成后再建 HNSW 索引。边导边建会让每次插入都维护图结构,速度慢一个数量级,内存峰值也更高。
十万条以上优先用 COPY,它比逐行 INSERT 少做解析和网络往返。psycopg 的 cursor.copy() 支持流式写入,但向量列的文本表示要由驱动适配;不确定时先用小批量验证,再放大。导入期间关闭自动提交、分批提交,单事务过大会让 WAL 膨胀并拖长锁等待。
2.3 近邻查询示例
-- ef_search 在会话级设置,线上应由应用按请求设置
SET hnsw.ef_search = 100;
-- 示例用 psql 变量 :qvec 代表查询向量,实际由客户端参数化传入
SELECT id, doc_id, content,
1 - (embedding <=> :qvec) AS similarity
FROM chunks
WHERE tenant = 'team-a'
ORDER BY embedding <=> :qvec
LIMIT 5;<=> 是余弦距离,数值越小越相似;1 - 距离 才是常见的相似度。排序必须用距离升序,不能对相似度升序排,否则会稳定返回最不相关的块。
用 Python 执行时同样要注册类型适配:
import numpy as np
with conn.cursor() as cur:
cur.execute("SET LOCAL hnsw.ef_search = 100")
cur.execute(
"""SELECT id, doc_id, content,
1 - (embedding <=> %s) AS similarity
FROM chunks
WHERE tenant = %s
ORDER BY embedding <=> %s
LIMIT %s""",
(query_vec, "team-a", query_vec, 5),
)
for row in cur.fetchall():
print(row[0], round(row[3], 4), row[2][:40])2.4 结果验证
| 检查项 | 期望 | 异常处理 |
|---|---|---|
| 扩展已安装 | \dx 出现 vector | 执行 CREATE EXTENSION vector |
| 维度一致 | 插入不报错 | 核对模型维度与表定义 |
| 相似度排序 | 第一条最相关 | 检查是否对距离升序 |
| 索引命中 | 大数据量下出现 Index Scan | 灌足数据后 ANALYZE |
| 过滤生效 | 只返回本租户 | 检查 WHERE 是否进查询 |
几千行的小表上出现顺序扫描是正常的,优化器认为全表扫描更便宜。判断索引是否有效,至少灌到几万行,再看 EXPLAIN。
看计划时不只看有没有 Index Scan,还要看过滤条件在哪里执行。出现 Filter 且 Rows Removed by Filter 很大,说明先取了大量候选再筛,应该调整过滤策略或提高索引的选择性。
三、索引类型与参数
3.1 索引分类
| 索引 | 结构 | 关键参数 | 适用 |
|---|---|---|---|
| Flat | 暴力扫描 | 无 | 十万条以内、精确基线 |
| IVF | 聚类倒排 | lists、probes | 百万级、批量导入 |
| HNSW | 多层近邻图 | m、ef_construction、ef_search | 在线低延迟查询 |
| PQ | 乘积量化 | 分段数、每段位数 | 内存受限、可接受降召回 |
四类索引的取舍方向不同:Flat 召回百分之百,但内存和扫描成本最高;HNSW 召回高,内存常见估算为原始向量的一点三到一点五倍;IVF 内存较低,召回受簇数和探测数影响;PQ 压缩最狠,召回损失也最大。构建时间通常与索引复杂度同向增长。
pgvector 本身主要提供 IVFFlat 与 HNSW,PQ 常见于 FAISS、Milvus 等引擎。选索引前先确认目标引擎支持哪一种,不要把其他产品的参数直接搬过来。
Flat 不是“没有索引”,而是把精确扫描作为基线。调参前先用 Flat 跑一遍,得到真实最近邻,后面的召回率才有参照。
IVF 先用 k-means 把向量聚成若干簇,查询时只扫距离最近的几个簇。lists 是簇数,probes 是查询时扫的簇数;probes 越大召回越高、延迟越高。簇数要与数据量匹配,太少退化成全扫,太多则每簇样本不足。
3.2 HNSW 参数
HNSW 建多层图:上层稀疏,用于快速跳转;下层稠密,用于精细定位。查询从上层入口点开始,逐层下降,在每层保留 ef_search 个候选。
| 参数 | 含义 | 常用起点 | 调大后 |
|---|---|---|---|
m | 每个节点的连边数 | 16 | 召回升,内存与构建时间升 |
ef_construction | 建图时的候选数 | 64 到 200 | 图质量升,构建变慢 |
ef_search | 查询时的候选数 | 40 到 100 | 召回升,延迟升 |
ef_search 是线上最该调的旋钮。它可以在会话级设置,不影响索引结构;同一份数据,ef_search=40 与 =200 的召回可能差几个百分点。上线前要用标注集画“召回—延迟”曲线,不能只看默认值。
建图的成本主要花在 ef_construction 上。候选越多,选出的邻居越优,图的质量越高,但构建时间近似线性增长。离线建库可以给到 200,在线增量写入则要压低,否则单条写入延迟不可控。两者没有统一值,按写入模式分开配置。
注意:
ef_search必须大于等于要返回的条数。设成 10 却取LIMIT 50,索引只能给出 10 个候选,剩余结果要么补扫要么缺失,行为随实现而异。
3.3 索引构建与维护
-- 数据灌完后建 HNSW;m 与 ef_construction 按内存和构建时间权衡
CREATE INDEX chunks_hnsw ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 收集统计信息,帮助规划器判断过滤选择性
ANALYZE chunks;
-- 查看索引大小
SELECT pg_size_pretty(pg_relation_size('chunks_hnsw'));构建阶段受 maintenance_work_mem 影响。内存不足时构建会落盘,速度明显变慢;内存过小时也可能直接失败。生产上可以临时提高该参数,构建完成后再调回;不要为了建索引长期占用大量内存,影响查询。
索引建好后并非一劳永逸。大量更新会让图结构碎片化,大量删除会留下不可见节点。PostgreSQL 里可以用 REINDEX INDEX CONCURRENTLY 在线重建,代价是额外的 I/O 和磁盘空间。
3.4 量化索引
内存不够时有三条路:降维、量化、换磁盘型索引。量化把 float32 压成 float16、int8 或二进制,查询时用压缩向量算近似距离。
pgvector 提供 halfvec 类型,可以按半精度存储并建索引;二进制量化适合先粗筛再精排。量化索引的召回损失必须在自己的数据上测,不能照搬别人的参数。压缩比越高,越依赖后面的全精度重排。
四、数据建模与过滤
4.1 表结构设计
| 字段 | 类型 | 作用 | 注意 |
|---|---|---|---|
id | bigserial | 主键 | 与原文表的关联键 |
doc_id | text | 文档标识 | 更新与删除按它定位 |
tenant | text | 租户隔离 | 必须进过滤条件 |
content | text | 原文 | 引用与重算依赖它 |
embedding | vector(n) | 语义向量 | 维度与模型一致 |
updated_at | timestamptz | 时效判断 | 冲突时用于裁决 |
原文是否放在向量表,取决于规模。几百万条以内,text 直接放表里最简单,事务一致、回查方便;再大就拆到对象存储,表里只留主键与摘要。
设计时先确定“一条记录代表什么”。以块为单位最灵活,但同一文档会被拆成多行,更新要按 doc_id 整组替换;以文档为单位则一行一条,检索粒度太粗。多数知识库选块粒度,再用 doc_id 和版本号维护一致性。
4.2 过滤执行方式
| 方式 | 做法 | 优点 | 风险 |
|---|---|---|---|
| 后过滤 | 先取全局 top-k,再按条件筛 | 实现简单 | 条件稀疏时筛空 |
| 前过滤 | 先缩小范围,再算距离 | 结果稳定 | 范围大时退化成全扫 |
| 迭代扫描 | 过滤后继续取,直到够数 | 兼顾两者 | 需要引擎支持 |
| 物理分区 | 按租户或时间分表 | 隔离彻底 | 管理成本高 |
后过滤的典型事故:库里一百万条,某租户只有一百条。查询先取全局 top-10,再过滤租户,结果为空,用户以为“没有资料”。条件选择性越高,这种事故越容易发生。
pgvector 0.8 起支持迭代扫描,过滤后候选不足时继续向索引索取:
-- 允许在过滤后继续扫描,直到凑够 LIMIT 或达到上限
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 20000;
SELECT id, content
FROM chunks
WHERE tenant = 'team-small'
ORDER BY embedding <=> :qvec
LIMIT 10;参数名与可选值以当前版本文档为准。迭代扫描不是无限扫描,达到上限仍可能返回不足,监控里要单独统计“返回条数小于 LIMIT”的比例。
4.3 多租户隔离
最稳的做法是物理隔离:大租户独立库或独立表,小租户共享表并强制带 tenant 条件。共享表方案要在查询层统一注入租户,不能依赖每个开发者记得写 WHERE。
-- 行级安全作为最后一道防线
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY chunks_tenant ON chunks
USING (tenant = current_setting('app.tenant_id', true));
-- 每个事务开始时设置,事务结束自动失效
SET LOCAL app.tenant_id = 'team-a';安全警告:租户标识必须由服务端从登录态推导,不能接受客户端传参。连接池下要用
SET LOCAL,不要用会话级SET,否则下一个借到连接的请求会继承上一个租户的身份。
4.4 权限与账号
应用账号只给 SELECT/INSERT/UPDATE/DELETE,不要用超级用户。迁移账号与运行账号分开,索引维护由运维账号执行。向量表往往包含原文,权限要按数据敏感级别收紧,不能因为“只是检索服务”就放宽。
五、检索流程
5.1 查询路径
查询向量先经过与建库一致的预处理,再进入索引。索引只负责给出候选,最终排序通常要回到原始向量重算,因为量化或图遍历得到的是近似距离。
过滤条件的位置决定结果稳定性。能进索引的过滤会参与图遍历;不能进索引的过滤只能在候选出来后执行。判断方法是看执行计划里过滤算子出现在索引扫描之前还是之后。
不同引擎的下推能力差别很大。支持下推的引擎在扩展候选时就能跳过不满足条件的节点;只能后过滤的引擎必须放大候选来弥补,延迟随放大倍数上升。选型时用最稀疏的租户实际跑一次,比看功能列表可靠。
5.2 距离算子与索引匹配
| 算子 | 含义 | 排序 | 对应算子类 |
|---|---|---|---|
<-> | 欧氏距离 | 升序 | vector_l2_ops |
<=> | 余弦距离 | 升序 | vector_cosine_ops |
<#> | 负内积 | 升序 | vector_ip_ops |
算子与算子类必须匹配。建索引时用 vector_cosine_ops,查询却按 <-> 排序,索引不会生效,规划器退化成顺序扫描。反过来,用 vector_l2_ops 却按 <=> 排,同样不匹配。
注意:归一化之后,余弦相似度、内积与欧氏距离的排序一致,但算子的返回值不同。
<#>返回负内积,必须升序排列才等价于内积降序;把负号漏掉会得到完全相反的排序。
5.3 执行计划验证
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM chunks
WHERE tenant = 'team-a'
ORDER BY embedding <=> :qvec
LIMIT 10;数据量足够时期望看到 Index Scan using chunks_hnsw。若仍是大表顺序扫描,依次核对:索引是否存在、算子类是否匹配、维度是否一致、过滤条件是否让优化器认为全扫更便宜。
Buffers: shared hit 很高说明索引和向量都在内存;read 很高说明发生了磁盘读,延迟会明显上升。后者先查内存配置和数据规模,不要先调 ef_search。
六、分片、副本与容量
6.1 分片
单机内存放不下,或写入吞吐超过单节点能力时,才考虑分片。顺序不能反:先确认单机确实撑不住,再引入分布式复杂度。
| 分片键 | 数据分布 | 查询影响 | 适用 |
|---|---|---|---|
tenant | 按租户倾斜 | 查询天然带租户,跨片少 | 多租户 SaaS |
doc_id 哈希 | 均匀 | 多租户查询要打所有片 | 单一大知识库 |
| 时间 | 新数据集中 | 旧数据查询少,热点明显 | 日志、归档 |
不要按时间分片做在线检索。新数据全压在一个分片上,写入和查询都热点化;时间适合做冷热分层,不适合做均匀分片。
6.2 副本与一致性
副本解决读吞吐和故障切换,代价是内存成倍增加、写入放大。副本同步通常是异步的,刚写入就去查可能查不到。
对“写完立即读”的场景,要么读主,要么在业务上接受秒级延迟。把“刚上传文档立刻提问”做成强一致,需要应用层等待索引可见,而不是假设副本已经追上。
故障切换要验证副本是否可读、是否追平。只看到副本进程存活不够,要比较复制位点差;差值为零或小于阈值才切流,否则会读到旧数据,表现为“刚改的文档没生效”。
6.3 备份与恢复
向量可以重算,原文、分块参数、模型版本不能丢。备份至少覆盖这三项,否则换模型后向量文件就是废数据。
| 对象 | 是否可重算 | 备份要求 |
|---|---|---|
| 原始文档 | 否 | 必须备份,最好异地 |
| 分块与元数据 | 否 | 与原文同版本备份 |
| 向量 | 是 | 可重算,但耗时 |
| 索引结构 | 是 | 恢复后重建更稳 |
PostgreSQL 用 pg_dump 做逻辑备份,或基础备份加 WAL 归档做时间点恢复。恢复后建议重建 HNSW 索引,再跑一遍召回评测;只恢复数据文件而索引状态不一致,查询可能报错或召回骤降。
备份要演练。只看“备份任务成功”没有意义,必须在新实例上恢复一次,确认扩展版本一致、索引能重建、原文能按主键回查。恢复耗时也是容量规划的一部分,几十 GB 的数据重建索引可能需要数十分钟。
6.4 容量估算
向量占用 = 条数 × 维度 × 每维字节
索引总占用 ≈ 向量占用 × 1.3 ~ 1.5
总内存 ≈ 索引总占用 × 副本数 × 1.3其中最后一项是给数据增长、索引碎片和对齐留的余量。
| 规模 | 1536 维 float32 向量 | 含 HNSW 单副本 | 三副本含余量 |
|---|---|---|---|
| 10 万 | 0.6 GB | 0.8 到 0.9 GB | 3.1 到 3.5 GB |
| 100 万 | 6.1 GB | 8 到 9 GB | 31 到 35 GB |
| 1000 万 | 61 GB | 80 到 90 GB | 310 到 350 GB |
这还没算原文、元数据和 WAL。内存不足时,HNSW 会频繁读盘,延迟从毫秒变成几十毫秒。要么加内存,要么量化或降维,要么换磁盘型索引。
七、完整链路:知识库检索服务
7.1 导入管线
def upsert_document(conn, doc_id: str, tenant: str, chunks: list[dict],
embed_fn) -> None:
texts = [c["content"] for c in chunks]
vecs = embed_fn(texts)
with conn.transaction():
with conn.cursor() as cur:
# 同一文档先删旧块,避免新旧版本同时被召回
cur.execute(
"DELETE FROM chunks WHERE doc_id = %s AND tenant = %s",
(doc_id, tenant),
)
cur.executemany(
"""INSERT INTO chunks
(doc_id, tenant, section, content, embedding)
VALUES (%s, %s, %s, %s, %s)""",
[(doc_id, tenant, c["section"], c["content"], v)
for c, v in zip(chunks, vecs)],
)删除与插入放在同一事务里。中途失败会整体回滚,不会留下“旧块已删、新块没进”的空窗。文档级别更新比逐块更新更稳,也更容易保证版本一致。
接口要幂等。同一份文档重复提交,结果应保持一致,不能产生重复块。幂等键用 doc_id + content_hash,入库前先查哈希;内容没变就跳过,既省 token,也避免无意义的索引更新。
7.2 查询服务
def search(conn, tenant: str, qvec, top_k: int = 10,
ef_search: int = 100):
with conn.transaction():
with conn.cursor() as cur:
# set_config 的第三个参数为 true 时仅当前事务生效,避免污染连接池
cur.execute("SELECT set_config('hnsw.ef_search', %s, true)",
(str(ef_search),))
cur.execute(
"""SELECT id, doc_id, section, content,
1 - (embedding <=> %s) AS similarity
FROM chunks
WHERE tenant = %s
ORDER BY embedding <=> %s
LIMIT %s""",
(qvec, tenant, qvec, top_k),
)
return cur.fetchall()set_config(..., true) 的最后一个参数表示仅当前事务生效,配合连接池不会污染下一个请求。若把 ef_search 直接拼进 SQL 字符串,既有注入风险,也无法按查询做差异化调参。
7.3 服务化与监控
| 指标 | 含义 | 告警建议 |
|---|---|---|
| 查询 P95 | 检索延迟 | 超过业务阈值持续 5 分钟 |
| 空结果率 | 返回条数为零的比例 | 突增说明过滤或索引异常 |
| 候选不足率 | 返回少于 LIMIT 的比例 | 高选择性租户要重点关注 |
| 索引命中率 | 走 ANN 索引的查询占比 | 下降说明计划翻转 |
| 内存与磁盘读 | 缓存命中情况 | 磁盘读持续升高要扩容 |
监控要按租户和查询类型拆分。全局 P95 正常,不代表小租户正常;小租户数据少,反而最容易触发过滤后候选不足。
八、更新、删除与重建
8.1 更新与删除
| 操作 | 后果 | 处理 |
|---|---|---|
| 频繁单条更新 | 图结构碎片化 | 批量合并,定期重建 |
| 删除 | 多为标记删除,空间不立即释放 | 定期 vacuum 或压实 |
| 大批量导入 | 索引膨胀、写入变慢 | 导入后统一建索引 |
| 修改维度 | 与表定义冲突 | 新建表,不能原地改 |
8.2 模型切换
换 embedding 模型等于换向量空间,新旧向量不可比。禁止在原表上逐行 UPDATE embedding,中途失败会得到一半新、一半旧的“缝合空间”,召回随机下降且难以定位。
-- 只复制列定义与默认值,不复制索引;HNSW 等灌完数据再建
CREATE TABLE chunks_v2 (
LIKE chunks INCLUDING DEFAULTS INCLUDING CONSTRAINTS
);
-- 灌入新模型向量后
CREATE INDEX chunks_v2_hnsw ON chunks_v2
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);切换顺序:全量灌入、建索引、用同一批查询对比 v1 与 v2 的 Recall@k、灰度切读、观察一周、保留旧表用于回滚。
8.3 回滚
回滚的前提是旧表还在、旧模型还能调用。切换前确认三件事:旧表没被删除、旧模型快照仍可访问、应用配置能在一分钟内切回。只保留新表,等于把回滚路径提前切断。
九、评测与优化
9.1 指标与工具
| 指标 | 定义 | 用途 |
|---|---|---|
| Recall@k | 前 k 条包含真实近邻的比例 | 衡量索引损失 |
| P95 延迟 | 95% 查询的耗时上限 | 判断用户体验 |
| 空结果率 | 无结果查询占比 | 发现过滤问题 |
| 构建时间 | 建索引耗时 | 评估运维窗口 |
| 内存占用 | 索引常驻内存 | 容量规划 |
真实近邻用 Flat 索引或暴力扫描得到,不要用另一套 ANN 结果当标准,否则测的是“两个近似有多像”,不是召回。
9.2 参数扫描
import time
def sweep_ef(conn, queries, gold, ef_values=(40, 100, 200)):
"""queries 为已编码的查询向量,gold 为对应的正确 id 列表。"""
results = []
for ef in ef_values:
hits, latencies = 0, []
for q, gold_ids in zip(queries, gold):
start = time.perf_counter()
rows = search(conn, "team-a", q, top_k=10, ef_search=ef)
latencies.append(time.perf_counter() - start)
hits += int(bool(set(gold_ids) & {r[0] for r in rows}))
results.append({
"ef_search": ef,
"recall@10": hits / len(queries),
"p95_ms": sorted(latencies)[int(len(latencies) * 0.95)] * 1000,
})
return results选满足召回阈值的最小 ef_search,写进配置,按查询类型覆盖。长尾查询可以调大,简单查询调小,不必全局一个值。
9.3 上线检查
| 检查项 | 通过标准(示例) |
|---|---|
| 索引命中 | 大数据量下出现 Index Scan |
| Recall@10 | 不低于业务阈值 |
| P95 延迟 | 不超过约定上限 |
| 过滤后条数 | 等于 LIMIT 或已扫完 |
| 删除可见性 | 删除后一次查询内不可见 |
| 重建可回滚 | 旧表仍可读、旧模型可调用 |
十、常见故障与排查
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 查询走顺序扫描 | 未建索引、算子类不匹配、维度不符 | EXPLAIN,核对索引与算子 |
| 过滤后结果为空 | 后过滤把候选筛没 | 迭代扫描、提高候选、按租户分区 |
| 召回突然变低 | ef_search 被调小、索引未建完 | 查会话参数与索引状态 |
| 延迟从毫秒变几十毫秒 | 内存不足、索引落盘 | 加内存、量化、缩小规模 |
| 写入越来越慢 | 边写边维护 HNSW、事务过大 | 批量导入,导入后建索引 |
| 磁盘涨得比预期快 | 删除只是标记、副本过多 | vacuum 或压实,核对副本数 |
| 新旧结果混在一起 | 原地换了模型 | 新表双写切换,全量重算 |
| 维度报错 | 模型维度与表定义不一致 | 统一维度或改表 |
| 多租户互相串 | 查询漏传租户条件 | 服务端强制注入,加回归测试 |
| 恢复后查询异常 | 索引与数据不一致 | 重建索引,再跑召回评测 |
排查顺序:
- 看查询计划,确认是否走 ANN 索引,过滤发生在索引前还是后。
- 用一条已知答案的查询,检查 top-10 是否包含它,分清“索引没命中”与“召回不够”。
- 单独跑带过滤和不带过滤两次,判断过滤选择性是否导致候选不足。
- 看内存、磁盘读和索引大小,排除落盘与容量问题。
- 最后才调
ef_search、量化参数或更换引擎,并用同一评测集复测。