知识库文档改了,检索结果里还是上周的段落,点进去原文已经不存在。排查三小时,最后发现不是召回不准,是索引里躺着旧向量——向量库对"更新"这件事的支持程度,和倒排索引完全不是一个量级。
向量索引的运维目标也不是"更新得快",而是三条:能删干净、能被核对、能在精度退化时回滚。重建只是手段,代价结构才是决策依据。
1. 写入到可检索的三段延迟
| 段 | 发生什么 | 延迟量级 | 谁负责 |
|---|---|---|---|
| 嵌入计算 | 调模型把文本变向量 | 百毫秒级/条,批量可摊薄 | 写入侧 |
| 索引构建 | 把向量插进图或聚类结构 | HNSW 近实时,IVF 需训练与分配 | 向量库 |
| 刷新可见 | 段合并、可见性切换 | 秒级到分钟级,按实现核对 | 向量库 |
三者相加才是"写了多久能搜到"。多数"改了搜不到"的工单卡在第二段:向量进了库没进索引,或进了索引但查询仍走旧段。
2. 与倒排索引的根本差异
| 维度 | 倒排索引(ES/Lucene) | 向量索引(HNSW/IVF) |
|---|---|---|
| 更新语义 | 文档级原地替换(删旧 + 写新,段内) | 插入新向量;删除多为标记 |
| 删除代价 | 标记删除 + 段合并时物理清除 | 标记后仍占内存,影响图连通性 |
| 增量友好度 | 高,refresh 后即可见 | 中,HNSW 可增量插入,IVF 需重训练 |
| 一致性手段 | 版本号、refresh、translog | 外部版本字段 + 双写/重建 |
| 精度随更新退化 | 轻微 | 明显(墓碑与碎片积累) |
取向由此确定:倒排索引能"边写边搜",向量索引必须假设索引是有寿命的易腐资产,需要定期重铸。
3. 索引类型的三角与参数调节方向
| 类型 | 精度 | 内存 | 构建时间 | 适用规模 | 更新友好 |
|---|---|---|---|---|---|
| 暴力检索(Flat) | 精确 | 高(全量原始向量) | 无 | 十万级以下 | 最好 |
| HNSW | 高,可调 | 很高(图边占大头) | 中 | 百万到亿级 | 好,可增量插入 |
| IVF-Flat | 中,依赖 nlist 与 nprobe |
中 | 需训练聚类 | 百万级以上 | 差,分布变化需重训 |
| IVF-PQ | 低到中 | 低(量化压缩) | 长 | 亿级以上 | 差 |
三者互斥:先固定一个不可让步的约束(通常是内存),其余两个接受劣化。
| 参数 | 调大 | 调小 | 过度调节的后果 |
|---|---|---|---|
M(HNSW 每层边数) |
召回升、内存涨、插入变慢 | 内存省、召回降 | M 过大,构建时间与内存双爆,收益趋零 |
efConstruction |
图质量好、构建慢 | 构建快、图变差 | 一次性成本,收益在构建期就定型 |
efSearch |
召回升、查询延迟线性涨 | 延迟低、召回掉 | 尾延迟不可预测,P99 先崩 |
nlist(IVF 簇数) |
每簇更小、精度升、训练变慢 | 精度降、扫描少 | 与数据量不匹配时,单簇过大等于暴力 |
nprobe |
扫描簇多、召回升 | 快、漏检多 | 调到接近 nlist 就退化成暴力检索 |
判断是否过度调节只有一条:固定查询集上召回不再涨、延迟继续涨,就是过了拐点。
4. 重建的成本结构
| 阶段 | 占比量级 | 决定因素 | 能否并行 |
|---|---|---|---|
| 嵌入计算 | 大头 | 文档数、chunk 数、模型吞吐 | 可,按批水平扩展 |
| 索引构建 | 次之 | M、efConstruction、向量维度 |
部分,受单机内存限制 |
| 数据切换 | 很小 | 别名切换或路由改配置 | 秒级 |
所以"重建慢"通常不是向量库慢,而是嵌入算不过来:先做嵌入缓存(按内容 hash 复用),再谈索引参数。
# 重建三步:建新集合 → 回填 → 切别名(旧集合留只读)
curl -X PUT "$VDB/collections/kb_v7" -d @schema.json
python backfill.py --dst kb_v7 --batch 256 --concurrency 8
curl -X POST "$VDB/aliases" -d '{"alias":"kb","collection":"kb_v7"}'
# 回滚只需把别名指回 kb_v6,不需要重新嵌入
旧集合的成本是内存,但它是分钟级回滚的唯一手段;内存不够时至少留快照文件。
5. 增量与重建的三种策略
| 策略 | 做法 | 残留问题 | 适用 |
|---|---|---|---|
| 删旧插新 | 按文档 id 删除旧 chunk,写入新 chunk | 删除是标记,墓碑累积,图连通性变差 | 更新频繁、量中等 |
| 软删标记 | 写入新版本,查询时按 valid_to 过滤 |
索引持续膨胀,过滤开销转移给查询 | 需要审计与回溯 |
| 按版本双写 | 新旧集合并存,按版本路由读 | 双份内存与双份嵌入成本 | 模型或切分策略升级 |
-- 删旧插新必须带版本,避免"删掉了刚写进去的新块"
DELETE FROM chunks WHERE doc_id = ? AND version < ?;
INSERT INTO chunks (doc_id, version, chunk_no, vector, valid_from)
VALUES (?, ?, ?, ?, now());
三种策略的共同残留是墓碑:删除标记不释放内存,图里还留着被删节点的边。判据——墓碑比例(删除数 / 总向量数)超过两成就重建,别再打补丁。
6. 一致性窗口怎么定
| 窗口 | 用户体验 | 实现代价 | 适用 |
|---|---|---|---|
| 秒级 | 改完立刻搜到 | 要求同步写入 + 强一致读 | 协作编辑、客服知识库 |
| 分钟级 | 短暂搜到旧内容,可接受 | 异步管道 + 幂等回填 | 大多数内部知识库 |
| 小时/天级 | 明确告知"次日生效" | 批量重建 | 文档站、法规库 |
窗口不是技术指标,是产品承诺。同时要定"看到旧内容时怎么办":标注版本或更新时间,比把窗口压到零便宜得多。
7. 嵌入模型升级:三选一
| 方案 | 成本 | 风险 | 适用 |
|---|---|---|---|
| 全量重算 | 一次嵌入全量 + 一次重建 | 升级窗口内不可用(除非有旧集合) | 数据量小、可停机 |
| 双索引并行 | 双份内存 + 双份写入 | 路由复杂,容易读到半成品 | 在线服务、必须可回滚 |
| 按桶混用 | 只重算部分桶 | 新旧向量不可比,同一查询跨桶排序失效 | 几乎不建议 |
硬约束:不同模型的向量空间不可混用。余弦相似度跨两个嵌入空间没有意义,"按桶混用"只在查询不跨桶时成立——而真实查询几乎总是跨桶。
8. 分片与副本在向量库里的意义
| 维度 | ES 分片 | 向量库分片 |
|---|---|---|
| 目的 | 水平扩容与并行检索 | 突破单机内存上限 |
| 查询放大 | 每分片取 topK 后归并 | 同左,但归并要跨网络传输向量或 id |
| 副本作用 | 读吞吐 + 高可用 | 主要是高可用,读扩展收益有限 |
分片不是免费吞吐:每次查询打到所有分片再归并,topK × 分片数 的中间结果都要传回。正确动机是内存放不下,不是"想更快";副本的价值在故障切换,不在查询性能。
9. 内存占用估算
原始向量:维度 × 数量 × 每维字节数
fp32: 768 × 3,000,000 × 4B ≈ 9.2 GB
fp16: 同上 × 2B ≈ 4.6 GB
int8: 同上 × 1B ≈ 2.3 GB
索引结构(HNSW 经验口径):约 数量 × M × 2 × 4B
3,000,000 × 32 × 2 × 4B ≈ 0.77 GB
总量 ≈ 原始向量 + 索引结构 + 段与墓碑余量(+10%~30%)
瓶颈在"数量"而非"维度":条数翻倍内存翻倍,而 chunk 切分粒度直接决定条数。
10. 召回率回归检测
{
"query_set": "eval/queries_v3.jsonl",
"items": [
{"q": "订单超时未支付怎么处理", "expect": ["doc-3312#c4", "doc-1180#c2"], "k": 10},
],
"gate": {"min_recall_at_k": 0.9, "max_regression_pp": 2.0}
}
固定查询集 + 期望文档集是唯一能进 CI 的判据,且要写成相对退化(较上一版掉不超过 2 个百分点)而非绝对召回率——绝对口径跨版本不可比。查询集要冻结版本,否则历史对比失效。
11. 灰度切换索引
# 按流量比例切读,两套索引并行一段时间
route:
read:
- {collection: kb_v6, weight: 0.9}
- {collection: kb_v7, weight: 0.1}
fallback_on_empty: kb_v6 # 新集合召回为空时回退,避免"搜不到"
compare_metrics: [recall_at_10, p99_latency, empty_result_rate]
最容易漏的是 empty_result_rate:新索引建漏一批文档时,表现不是"变差"而是"部分查询直接空",它比平均召回更早报警。
12. 备份与恢复:能重算 vs 必须备份
| 资产 | 能否重算 | 备份策略 |
|---|---|---|
| 原始文本与元数据 | 不能,必须持久化 | 常规数据库备份 |
| 嵌入向量 | 能,但成本 = 全量重算 | 有嵌入缓存则不必单独备份 |
| 索引结构(HNSW 图) | 能,成本 = 一次重建 | 通常不备份,重建替代 |
判据是重算成本 vs 备份成本:向量能重算,所以多数团队不备份索引本体;但重算耗时即恢复时长,要六小时的就得备份。
13. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 文档已更新,检索仍是旧内容 | 写入进了库但没进索引 / 读的是旧别名 | 核对索引内条数与 indexed_at |
| 更新后检索变差 | 墓碑累积,图连通性退化 | 算墓碑比例,超两成重建 |
| 召回突然下降 | 换了嵌入模型或归一化方式 | 比对向量范数,确认是否同一空间 |
| 内存持续上涨不回落 | 软删数据未物理清理 | 关掉纯软删,改删旧插新 + 定期重建 |
| 查询 P99 抖动 | efSearch 过大或分片过多导致归并放大 |
降 efSearch,减少跨分片中间结果 |
14. 边界:暴力检索反而更省心
| 场景 | 做法 | 理由 |
|---|---|---|
| 向量数在十万级以下 | Flat 暴力检索 | 全扫的延迟可接受,精度最高,无参数可调错 |
| 更新极少(季度级) | 全量重建代替增量 | 增量的墓碑治理成本高于重建 |
| 精度要求极高(合规检索) | 精确检索 + 后置重排 | 近似索引的召回损失不可接受 |
| 只有过滤条件命中少数文档 | 先过滤再向量比较 | 候选集小,索引结构无用 |
最后一条常被忽略:过滤砍掉的候选,比索引加速的部分更多。租户、时间、类型这类高选择性过滤先把候选压到几千条,再谈用什么索引。
15. 自测
- 文档更新后检索仍是旧内容,三段延迟里最常卡在哪一段?
- 向量索引为什么不能像倒排索引那样"边写边搜"?
efSearch调到多大就算过度调节,判断依据是什么?- 重建的成本大头通常在哪一步,优化顺序应该怎么排?
- 墓碑比例到什么程度该重建而不是继续增量?
- 什么规模下暴力检索比 HNSW 更合适?