L2.2 特征与向量存储
三维坐标
layer: L2(数据与算子层)|level: Engineer / Senior|pillar: 数据上一篇我们把 ETL 管线的「数据搬运」讲透了。本文聚焦数据流向模型前的最后一公里存储:训练侧的 Feature Store(特征仓库) 如何保证「训练用什么、线上推理就拿什么」,以及 RAG / 检索召回侧的 Vector DB(向量数据库) 如何在「召回率 / QPS / 内存」三角中做工程权衡。这两者是 L2 数据层通往 L3 训练与 L6 应用的两条主动脉。
学习目标
- 前置知识:读过 L2.1 数据管线(知道 ETL / 批流处理的「数据搬运」职责);会写基础 Python 与 SQL;知道 embedding 是「把文本/图像映射成高维向量」即可。无需 Faiss / Feast 使用经验,也无需 GPU。
- 学完产出:① 能画出 Feature Store「同一份特征定义 → 离线 / 在线双 store」的物化架构,并说清两套 store 在 SLA、后端、服务对象上的分工;② 能用「只取事件时间戳之前最近一次有效值」一句话讲清 point-in-time join 如何消灭训练-服务偏差(training-serving skew),并解释「直接 join 最新值」为什么会让离线指标虚高、上线崩盘;③ 能对比 HNSW / IVF / PQ 三类索引在「召回率 / QPS / 内存」不可能三角中的取舍,记住「HNSW 用内存换召回、PQ 用精度换内存、IVF 用一次性 train 换查询裁剪」;④ 能为给定的「向量规模 + 内存预算 + 召回目标」做索引选型推演,会用「N × dim × 4 字节」估算 HNSW 内存、判断何时必须上 IVF-PQ;⑤ 亲手用 Docker 起一个 Qdrant,量化 HNSW 的
m/ef_construct/ef_search参数对 recall@10 与延迟的真实影响。 - 阅读姿势:盯住一条主线——「特征与向量层的所有设计,都是为了让『离线算好的高维数值』以两种相反的 SLA 同时服务出去」。无论是 Feature Store 的离线/在线双 store,还是 Vector DB 的召回-成本三角,本质都在一份数据上同时满足「吞吐大、可追溯」与「延迟低、毫秒命中」这对矛盾需求。
背景与现状
Feature Store 与 Vector DB 看似两套系统,本质都在解决同一个工程难题:把「离线算好的高维数值」以两种截然不同的 SLA 同时服务出去——离线侧要「 吞吐大、可追溯历史」,在线侧要「延迟低、毫秒级命中」。
- Feature Store 服务的是结构化特征(用户近 7 天点击数、商品 CTR),核心矛盾是 训练-服务偏差(training-serving skew):模型训练时用的是某个历史时刻的特征值,而线上推理必须取到逻辑一致的同一份特征,否则离线 AUC 很高、上线效果崩盘。
- Vector DB 服务的是非结构化语义向量(文本 / 图像 embedding),核心矛盾是 近似最近邻(ANN)的召回-成本权衡:在十亿级向量里做暴力 KNN 是 不可接受的,必须用近似索引把延迟压到毫秒,代价是放弃一部分召回率。
从产业演进看:
- 2017–2020:特征工程靠各团队手写 SQL + Redis,特征复用率极低、口径不一致,Uber 的 Michelangelo 与开源的 Feast 把「特征即资产」的理念工业化。
- 2020–2022:embedding 检索从「Faiss 库」走向「数据库产品」,Milvus、Qdrant、Weaviate 把 ANN 索引 + 元数据过滤 + 分布式 + 持久化打包成服务。
- 2023 至今:RAG 与 Agent 爆发,Vector DB 成为大模型应用的标配存储,选型从「能用」转向「召回质量 / 成本 / 混合检索能力」的精细化竞争。
业界信号:
facebookresearch/faiss、milvus-io/milvus、qdrant/qdrant均为数万 Star 量级;Feast 成为 MLOps 特征层的事实标准。这说明「数据怎么存、怎么取」已不再是附属问题,而是直接决定模型上线效果与服务成本的胜负手。
原理与架构
2.1 Feature Store:离线 / 在线双 store 与 point-in-time join
Feast 的核心抽象是同一份特征定义,物化(materialize)到两套存储:
两套 store 的职责分工:
| 维度 | Offline Store(离线) | Online Store(在线) |
|---|---|---|
| 典型后端 | BigQuery / Snowflake / Parquet 列式 | Redis / DynamoDB / KV 引擎 |
| SLA | 高吞吐、可扫历史全量 | P99 < 10ms、单点查询 |
| 服务对象 | 模型训练 / 批量评估 | 在线推理(实时取特征) |
| 核心 API | get_historical_features | get_online_features |
最关键的原理:point-in-time join(时间点连接)防止特征穿越(feature leakage)。 训练样本带有事件发生时间戳,Feast 在拼接特征时,只允许取「该时间戳之前最近一次有效的特征值」,而非取「当前最新值」。如果直接 join 最新特征,模型就会在训练时「看到未来」——比如用「用户今天的总消费」去预测「用户昨天是否会下单」,离线指标虚高,上线必然滑铁卢。
需要强调的是:Feature Store 的价值 90% 在「一致性」而非「存储」——它用一份 Registry 强制离线训练与在线推理用同一套特征口径 + 时间语义,从工程上消灭 training-serving skew。
2.2 Vector DB:HNSW / IVF / PQ 三大索引原理与权衡
向量检索的本质是在高维空间做近似最近邻(ANN)。三类主流索引代表了三条不同的工程取舍路线:
HNSW(Hierarchical Navigable Small World,分层可导航小世界图)
- 原理:构建多层图,上层稀疏长边用于「跨大区粗略导航」,下层稠密短边用于「局部精搜」,查询时自顶向下贪心走向最近邻。
- 核心参数:
m(每个节点的出边数,越大召回越高、内存越大)、ef_construct(建图时候选队列大小,越大图质量越高、建图越慢)、ef_search( 查询时候选队列,越大召回越高、延迟越大)。 - 权衡:召回率最高、延迟最低,是当前 RAG 的主流默认;代价是图结构常驻内存,内存占用大,且增量插入会逐渐劣化图质量。
IVF(Inverted File,倒排文件)
- 原理:先用 k-means 把全量向量聚成
nlist个簇并记录质心,查询时只在最近的nprobe个簇内搜索,把搜索空间从 降到约 。 - 权衡:需要训练(train)阶段跑 k-means,对数据分布敏感;
nprobe调小则快但召回低、调大则召回回升但接近暴力搜索。适合数据相对静态、可离线建索引的批量场景。
PQ(Product Quantization,乘积量化)
- 原理:把 维向量切成 段子向量,每段用一个小码本(如 256 个码字)量化为一个 1 字节的码本 ID,距离计算退化为查表 + 累加。一个 768 维 float32 向量(3072 字节)可压到 字节(如 96 字节)。
- 权衡:内存压缩比极高(10–30 倍),但有量化误差导致精度损失。通常与 IVF 组合成
IVF-PQ,在十亿级、内存受限场景做粗筛,再用原始向量精排(rerank)。
三者权衡总览(recall / QPS / 内存的不可能三角):
| 索引 | 召回率 | QPS / 延迟 | 内存占用 | 是否需 train | 典型场景 |
|---|---|---|---|---|---|
| 暴力 Flat | 100%(基准) | 最慢 | 原始大小 | 否 | 小数据、做 ground truth |
| HNSW | 很高 | 快 | 大(图常驻) | 否 | 百万级 RAG、低延迟召回 |
| IVF | 中-高(看 nprobe) | 快 | 中 | 是(k-means) | 静态大数据、可离线建索 引 |
| IVF-PQ | 中(有损) | 很快 | 极小 | 是 | 十亿级、内存受限、粗筛+精排 |
核心在于:没有「最好的索引」,只有「给定 recall 目标下,QPS 与内存预算的最优解」。HNSW 用内存换召回与延迟,PQ 用精度换内存,IVF 用一次性 train 换查询裁剪。 选型第一步永远是先固定召回目标(如 recall@10 ≥ 0.95),再在此约束下比成本。
2.3 Embedding 批量回填管线与 Qdrant / Milvus 选型
线上向量库不是凭空填满的,需要一条批量 embedding + 回填(backfill)管线:
管线工程要点:分片入队避免一次性加载撑爆显存;embedding 阶段在 GPU 上做大 batch 推理是吞吐关键;写入用幂等 upsert(按 ID) 以支持失败重跑与增量回填;大规模回填建议先关索引、灌完再统一建索引,比边写边建快数倍。
Qdrant vs Milvus 选型:
| 维度 | Qdrant | Milvus |
|---|---|---|
| 实现语言 | Rust,单二进制易部署 | Go/C++,组件多(含 etcd/MinIO) |
| 架构 | 轻量,中小规模开箱即用 | 存算分离,面向十亿级超大规模 |
| 索引 | HNSW(主),支持量化 | HNSW / IVF / PQ / DiskANN 等丰富 |
| 过滤检索 | payload 过滤强、filterable HNSW | 标量过滤 + 混合检索 |
| 运维成本 | 低,适合快速起步 / 中等规模 | 较高,适合大规模专业团队 |
| 选型建议 | RAG / 中小规模 / 想少运维 | 十亿级 / 多索引算法 / 已有 K8s 平台 |
经验法则:先用 Qdrant 把业务跑通(单容器即可起,本篇实验即是),当数据量逼近十亿级或需要 DiskANN / 多索引算法时,再评估迁移到 Milvus。过早上 Milvus 往往把运维复杂度引入到了不需要它的规模。
动手实践:Docker 起 Qdrant 对比 HNSW 参数
实验目标:用 Docker 纯 CPU 起一个 Qdrant,灌入随机 embedding,亲手对比 HNSW 不同 m / ef_construct / ef_search 参数对召回率与查询延迟的影响,建立对「召回-延迟权衡」的第一手量化感知。产出物:一张参数 → recall@10 与延迟的对照表。
本实验纯 CPU 可跑,无需 GPU。Qdrant 服务端用 Docker 运行,客户端用
qdrant-client。若有 GPU 也不会用到——本实验关注的是索引检索而非 embedding 推理。
3.1 启动 Qdrant(Docker · 纯 CPU)
# 拉起 Qdrant 单容器,暴露 REST(6333) 与 gRPC(6334)
docker run -d --name qdrant-lab \
-p 6333:6333 -p 6334:6334 \
qdrant/qdrant:latest
# 健康检查:应返回版本信息 JSON
curl -s http://localhost:6333/ | head -c 200
3.2 环境准备
python3 -m venv .venv && source .venv/bin/activate
pip install "qdrant-client>=1.9" numpy
3.3 代码:灌入 embedding 并对比 HNSW 参数
import time
import numpy as np
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, HnswConfigDiff, PointStruct, SearchParams,
)
DIM, N, NQ, TOPK = 128, 20000, 200, 10
rng = np.random.default_rng(42)
# 构造数据集与查询集(随机向量,仅用于对比相对趋势)
data = rng.normal(size=(N, DIM)).astype("float32")
queries = rng.normal(size=(NQ, DIM)).astype("float32")
client = QdrantClient(path="/tmp/ai_infra_qdrant_lab") # 本地嵌入式,无需 Docker
# 或使用 Docker:client = QdrantClient(url="http://localhost:6333")
def l2_topk(q, base, k):
d = ((base - q) ** 2).sum(axis=1)
return set(np.argsort(d)[:k].tolist())
# 暴力计算 ground truth(CPU 即可,仅 2 万条)
gt = [l2_topk(queries[i], data, TOPK) for i in range(NQ)]
# 对比不同 (m, ef_construct, ef_search) 组合
configs = [
dict(m=8, ef_construct=64, ef_search=32),
dict(m=16, ef_construct=100, ef_search=64),
dict(m=32, ef_construct=200, ef_search=128),
]
print(f"{'m':>3} {'ef_con':>7} {'ef_srch':>8} {'recall@10':>10} {'p50(ms)':>9}")
for cfg in configs:
coll = f"lab_m{cfg['m']}_efc{cfg['ef_construct']}"
if client.collection_exists(coll):
client.delete_collection(coll)
# 建集合:HNSW 的 m / ef_construct 在 hnsw_config 指定
client.create_collection(
collection_name=coll,
vectors_config=VectorParams(size=DIM, distance=Distance.EUCLID),
hnsw_config=HnswConfigDiff(m=cfg["m"], ef_construct=cfg["ef_construct"]),
)
client.upsert(
collection_name=coll,
points=[PointStruct(id=i, vector=data[i].tolist()) for i in range(N)],
)
hits, lat = 0, []
for i in range(NQ):
t0 = time.perf_counter()
res = client.query_points(
collection_name=coll,
query=queries[i].tolist(),
limit=TOPK,
# ef_search 在查询时通过 hnsw_ef 控制
search_params=SearchParams(hnsw_ef=cfg["ef_search"]),
).points
lat.append((time.perf_counter() - t0) * 1000)
got = {p.id for p in res}
hits += len(got & gt[i])
recall = hits / (NQ * TOPK)
p50 = float(np.percentile(lat, 50))
print(f"{cfg['m']:>3} {cfg['ef_construct']:>7} {cfg['ef_search']:>8} "
f"{recall:>10.3f} {p50:>9.2f}")
3.4 运行与观察
python hnsw_bench.py
你会看到类似规律(随机数据下绝对值会波动,关注趋势):
m与ef_construct增大 → 图更稠密、建图更慢,但 recall@10 上升;ef_search增大 → 查询时探索更多候选,recall 上升但 p50 延迟同步上升;- 这正是 2.2 节「召回-延迟权衡」的实证:调参的本质是在固定召回目标下找延迟最小的那组参数。
踩坑预警 (Gotchas)
- 随机向量召回会偏低别误判:纯随机高斯向量没有聚类结构,HNSW 召回天然不如真实 embedding。本实验只看参数间的相对趋势,不要把绝对 recall 当真实业务指标。
ef_search≥limit否则结果不足:hnsw_ef小于topk时召回会异常低甚至报错,查询时务必让ef_search ≥ TOPK。- 大批量灌库要先关索引:本实验只有 2 万条直接 upsert 没问题;真实回填百万级时,应配置在阈值前不建 HNSW(
indexing_threshold),灌完再统一建,避免边写边建拖慢吞吐。 - 容器内存默认无上限:HNSW 常驻内存,大数据集下给
docker run加--memory限制反而会触发 OOMKill;生产应按向量数 ×m估算内存后再分配。 query_pointsvs 旧search:新版qdrant-client用query_points,老教程里的client.search(...)已弃用,注意 API 版本对齐。