跳到主要内容

L2.2 特征与向量存储

三维坐标 layer: L2(数据与算子层)level: Engineer / Seniorpillar: 数据

上一篇我们把 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 StoreVector DB 看似两套系统,本质都在解决同一个工程难题:把「离线算好的高维数值」以两种截然不同的 SLA 同时服务出去——离线侧要「吞吐大、可追溯历史」,在线侧要「延迟低、毫秒级命中」。

  • Feature Store 服务的是结构化特征(用户近 7 天点击数、商品 CTR),核心矛盾是 训练-服务偏差(training-serving skew):模型训练时用的是某个历史时刻的特征值,而线上推理必须取到逻辑一致的同一份特征,否则离线 AUC 很高、上线效果崩盘。
  • Vector DB 服务的是非结构化语义向量(文本 / 图像 embedding),核心矛盾是 近似最近邻(ANN)的召回-成本权衡:在十亿级向量里做暴力 KNN 是 O(N)O(N) 不可接受的,必须用近似索引把延迟压到毫秒,代价是放弃一部分召回率。

从产业演进看:

  • 2017–2020:特征工程靠各团队手写 SQL + Redis,特征复用率极低、口径不一致,Uber 的 Michelangelo 与开源的 Feast 把「特征即资产」的理念工业化。
  • 2020–2022:embedding 检索从「Faiss 库」走向「数据库产品」,MilvusQdrantWeaviate 把 ANN 索引 + 元数据过滤 + 分布式 + 持久化打包成服务。
  • 2023 至今:RAG 与 Agent 爆发,Vector DB 成为大模型应用的标配存储,选型从「能用」转向「召回质量 / 成本 / 混合检索能力」的精细化竞争。

业界信号facebookresearch/faissmilvus-io/milvusqdrant/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、单点查询
服务对象模型训练 / 批量评估在线推理(实时取特征)
核心 APIget_historical_featuresget_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 个簇内搜索,把搜索空间从 NN 降到约 NnprobenlistN \cdot \frac{nprobe}{nlist}
  • 权衡需要训练(train)阶段跑 k-means,对数据分布敏感;nprobe 调小则快但召回低、调大则召回回升但接近暴力搜索。适合数据相对静态、可离线建索引的批量场景。

PQ(Product Quantization,乘积量化)

  • 原理:把 DD 维向量切成 mm 段子向量,每段用一个小码本(如 256 个码字)量化为一个 1 字节的码本 ID,距离计算退化为查表 + 累加。一个 768 维 float32 向量(3072 字节)可压到 mm 字节(如 96 字节)。
  • 权衡内存压缩比极高(10–30 倍),但有量化误差导致精度损失。通常与 IVF 组合成 IVF-PQ,在十亿级、内存受限场景做粗筛,再用原始向量精排(rerank)。

三者权衡总览(recall / QPS / 内存的不可能三角)

索引召回率QPS / 延迟内存占用是否需 train典型场景
暴力 Flat100%(基准)最慢原始大小小数据、做 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 选型

维度QdrantMilvus
实现语言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

你会看到类似规律(随机数据下绝对值会波动,关注趋势):

  • mef_construct 增大 → 图更稠密、建图更慢,但 recall@10 上升
  • ef_search 增大 → 查询时探索更多候选,recall 上升但 p50 延迟同步上升
  • 这正是 2.2 节「召回-延迟权衡」的实证:调参的本质是在固定召回目标下找延迟最小的那组参数

踩坑预警 (Gotchas)

  • 随机向量召回会偏低别误判:纯随机高斯向量没有聚类结构,HNSW 召回天然不如真实 embedding。本实验只看参数间的相对趋势,不要把绝对 recall 当真实业务指标。
  • ef_searchlimit 否则结果不足hnsw_ef 小于 topk 时召回会异常低甚至报错,查询时务必让 ef_search ≥ TOPK
  • 大批量灌库要先关索引:本实验只有 2 万条直接 upsert 没问题;真实回填百万级时,应配置在阈值前不建 HNSW(indexing_threshold),灌完再统一建,避免边写边建拖慢吞吐。
  • 容器内存默认无上限:HNSW 常驻内存,大数据集下给 docker run--memory 限制反而会触发 OOMKill;生产应按向量数 × m 估算内存后再分配。
  • query_points vs 旧 search:新版 qdrant-clientquery_points,老教程里的 client.search(...) 已弃用,注意 API 版本对齐。

深入思考

下面三题每题先给题干,再用 <details> 折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。

思考题 1:特征穿越排查清单

某推荐模型离线 AUC 0.92,上线后 CTR 远低于预期。结合 2.1 节的 point-in-time join,列出你怀疑「特征穿越(feature leakage)」的自查清单——如何确认训练样本拼接的特征是「事件时间点之前的值」而非「当前最新值」?Online Store 与 Offline Store 的物化时机不一致,又会引入什么新的偏差?

展开参考答案(含特征穿越时间轴判定图 + 算一遍)

结论:「离线高、上线崩」最经典的元凶就是特征穿越——训练时用了「事件时间点之后才知道」的信息。判定方法是把每个特征的「值生效时刻」与「样本事件时刻」放在同一根时间轴上比对:只要特征值的时间戳晚于(或等于)事件时刻,就是穿越。修复手段就是强制走 point-in-time join,只取事件时刻之前最近一次有效值。

自查清单(逐条对照):

  1. 特征是否带「生效时间戳」:每个特征值必须有 event_timestamp。没有时间维度的特征(直接 join 维表最新值)天然有穿越风险。
  2. 拼接是否用 point-in-time join:确认用的是 get_historical_features(按样本时间戳回溯最近有效值),而非朴素 LEFT JOIN 最新值
  3. 是否含「事后聚合」窗口:检查「用户近 7 天点击数」这类滑窗,其窗口右边界必须 ≤ t_event,不能把事件当天/之后的行为算进去。
  4. 标签泄漏:标签衍生的特征(如「该用户最终是否转化」反推的统计量)绝不能进特征。
  5. 离线/在线口径 diff:抽样几个 entity,分别用离线 join 与在线 get_online_features 取值,逐字段比对是否一致。

用具体例子算一遍(穿越如何抬高 AUC):

  • 样本:预测「用户 10:00 是否点击商品 A」,标签在 10:00 产生。
  • 穿越特征:拼接「用户当日(截至 24:00)总点击数」。若用户 10:00 点了 A,这个「当日总点击」就包含了被预测的那次点击——特征里直接编码了答案。
  • 后果:离线模型轻松学到「当日点击多 → 容易点击」的伪相关,AUC 冲到 0.92;上线时 10:00 这一刻根本拿不到当日完整统计(未来还没发生),特征分布完全不同,CTR 崩盘。

物化时机不一致引入的新偏差:Offline Store 持有历史全量,Online Store 靠 materialize 定时刷新。如果在线物化有延迟(比如每小时刷一次),线上推理 10:05 取到的可能是 9:00 的特征快照,而训练时 point-in-time join 取的是 10:00 前最近值——两者时间精度不一致,产生「物化滞后偏差」。对策:让离线 join 的时间语义对齐在线物化频率(训练也按「整点快照」取值),或缩短物化间隔、用流式物化逼近实时。

思考题 2:十亿级内存受限的索引选型推演

你要为一个 5 亿向量、768 维、内存预算只有 64 GB、要求 recall@10 ≥ 0.9、QPS ≥ 500 的检索服务选索引。纯 HNSW 内存放得下吗(先估算)?为什么这里 IVF-PQ + 精排 几乎是唯一可行解?nlist / nprobe / PQ 的 m 你会如何起步调?

展开参考答案(含内存预算决策树图 + 算一遍)

结论:纯 HNSW 光是存原始向量就要约 1.5 TB,远超 64 GB 预算,直接出局。唯一能把 5 亿 × 768 维塞进 64 GB 的路线是 PQ 压缩(配 IVF 做查询裁剪),用「极小内存 + 一次 train」换近似召回,再用原始向量对粗筛结果 rerank 补回精度。

用具体数字算一遍:

  1. 原始向量:5 亿 × 768 维 × 4 字节(float32)= 5×10⁸ × 768 × 4 ≈ 1.54 TB
  2. 纯 HNSW:除原始向量外,还要存图的出边,每节点约 m 条边、每边 4 字节。m=16 时仅图就 5×10⁸ × 16 × 4 ≈ 32 GB,再叠加 1.54 TB 向量 → 合计 约 1.57 TB,是预算的 24 倍,放不下。
  3. IVF-PQ:把 768 维切 m=96 段、每段 1 字节码 → 每条向量压到 96 字节。5×10⁸ × 96 ≈ 48 GB,再加 IVF 质心与倒排结构(几 GB),总量约 50–55 GB,能塞进 64 GB
  4. 精排成本:PQ 是有损的,recall@10 ≥ 0.9 靠 PQ 粗筛达不到 → 用 IVF-PQ 取 top-200 候选,再回原始向量(存磁盘/SSD)算精确距离重排出 top-10,把召回补回目标线。

参数起步建议(再压测迭代):

参数含义起步值调参方向
nlistIVF 簇数≈ √N ≈ 2万~6万越大簇越细、单簇越小,查询更快但 train 慢
nprobe查询搜几个簇16~64 起调大 → 召回↑、延迟↑(覆盖 recall 目标)
PQ m子向量段数96(768 维整除)调大 → 精度↑、内存↑(受 64 GB 约束)
rerank top-K精排候选数200调大 → 召回↑、延迟↑

先固定 recall@10 ≥ 0.9 为硬约束,在 64 GB 内存与 QPS ≥ 500 的预算下,nprobe 和 rerank top-K 这两个「查询时旋钮」逼近召回目标,再看延迟是否还满足 QPS——这正是 2.2 节「先固定召回、再比成本」的实操落地。

思考题 3:特征库与向量库的双系统融合取舍

RAG 场景里,「用户画像特征(结构化)」与「文档语义向量(非结构化)」常常都要参与召回排序。你会用 Feature Store + Vector DB 两套系统各管一摊,还是寻求融合?请论证两种架构在一致性、延迟、运维复杂度上的取舍。

展开参考答案(含双系统 vs 融合架构对比图 + 对比表)

结论:两套系统「各管一摊」职责清晰、各自最优,但召回时要做跨系统 join,多一跳网络延迟、多一份一致性协调;「融合」(向量库直接挂载结构化 payload 做过滤/打分)省掉跨系统 join、延迟更低,但牺牲了 Feature Store 的 point-in-time 时间语义与特征复用能力。选择取决于「结构化特征是否需要严格时间一致性」——需要就分两套,只是简单过滤就融合进向量库 payload。

三维度对比:

维度方案 A:两套系统各管一摊方案 B:融合进向量库 payload
一致性强:Feature Store 保住 point-in-time 时间语义与训练-服务一致,特征可跨模型复用弱:payload 多为「当前快照」,难做严格时间点回溯;特征复用要在向量库外另建口径
延迟多一跳:召回时要跨系统取特征再 join,网络往返叠加(P99 受两系统中较慢者拖累)低:filterable HNSW 一次召回即带过滤/打分,省掉跨系统 join
运维复杂度高:两套存储 + 物化管线 + 一致性协调,但职责边界清晰、各自可独立扩缩低:单系统,少一份运维;但向量库要承担结构化字段的更新/索引压力,膨胀后反噬

怎么选(决策落点):

  • 结构化特征需要严格时间一致性 / 跨多模型复用(如风控、推荐排序里的画像特征)→ 选 方案 A,让 Feature Store 守住 point-in-time 语义,向量库只管语义召回,召回后在排序层做 join。
  • 结构化字段只是简单过滤条件(如「只召回某类目 / 某语言 / 未删除的文档」)→ 选 方案 B,把这些字段作为 payload 挂在向量上,用 filterable HNSW 一次召回,省延迟省运维。
  • 常见落地是「混合」:低基数、强过滤的字段进向量库 payload(走方案 B 的快路径);高价值、需时间一致性的画像特征留在 Feature Store(走方案 A),二者在排序层汇合——这与 2.1/2.3 节强调的「一致性 90% 是 Feature Store 的价值、向量库强在过滤召回」一脉相承。

延伸阅读

1. 核心 Paper

  • Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs(Malkov & Yashunin, 2016)— HNSW 的原始论文,理解分层图导航的数学本质。
  • Product Quantization for Nearest Neighbor Search(Jégou et al., 2011)— PQ 的奠基之作,理解「切段量化 + 查表」的压缩原理。
  • Billion-scale similarity search with GPUs(Johnson et al., Faiss, 2017)— 理解 IVF-PQ 在十亿级的工程实现。

2. 相关高 Star 仓库与源码必读路径

  • feast-dev/feast — 看 sdk/python/feast/feature_store.pyget_historical_features 的 point-in-time join 实现。
  • qdrant/qdrant — 看 lib/segment/src/index/hnsw_index/ 下 HNSW 建图与搜索的 Rust 实现。
  • facebookresearch/faiss — 看 faiss/IndexIVFPQ.cpp,理解 IVF 与 PQ 如何组合。
  • milvus-io/milvus — 看其存算分离架构与多索引插件设计,对照 Qdrant 的单体取舍。

3. 优质博客 / 视频

  • Qdrant 官方博客「HNSW vs IVF」与「Quantization」系列,配图讲清参数对召回-延迟的影响。
  • Pinecone Learning Center 的「Vector Indexes」系列,索引权衡的入门最佳读物。
  • Feast 官方文档「Point-in-time joins」章节,把 training-serving skew 讲透。

下一篇L2.3 经典 CUDA 编程精要与进阶:从「数据怎么存取」转向「算子怎么编译到硬件」,深入 CUDA 编程模型与性能优化。