DAY 40 / PHASE 4 · ENGINEERING

Data Pipeline for AI

Embedding ETL · Incremental Index · Index Versioning · Feature Store

2026-06-19 · BigCat

模型每次升级都在变好,但喂给它的向量库可能还停在上一个坐标系——数据 pipeline 才是 RAG 不随时间退化的真正基础设施。

// WHY THIS MATTERS

大多数 RAG demo 死在「跑通即终点」:一次性把文档全量嵌入、塞进向量库、上线。真正的痛苦在第二天才开始——文档改了一行,你是重嵌整库还是只补那一块?删掉的页面还会不会被召回?换个更好的 embedding model,旧向量怎么办?你给 reranker 喂的「用户近 7 天点击」特征,训练和线上算的是不是同一个值?这些都不是模型问题,是 数据 pipeline 工程问题,且每一个都会悄悄腐蚀线上质量而不报错。这一期把 AI 数据流水线拆成四个最容易塌方的承重点:嵌入 ETL 的成本结构增量索引的 diff 与删除嵌入模型升级的索引迁移特征存储的离线/在线一致性。每一点都给可直接抄的实现骨架与失败模式。

// 01

嵌入 ETL:内容哈希去重是流水线的命门

论断:embedding 调用是 pipeline 里最贵最慢的一步,把它当成纯函数缓存,而不是每次重算。

背景与原理

一条 AI 数据流水线本质是 extract → chunk → embed → load。前三步里,extract 和 chunk 几乎免费(CPU、毫秒级),embed 才是瓶颈:它要走网络、按 token 计费、有 rate limit。所以工程上的第一原则是——把 embed(chunk) 当成纯函数:相同文本必出相同向量,于是可以按 hash(model_id + chunk_text) 做缓存键。一旦同一块内容出现在多个文档、或同一文档被反复 ingest,缓存命中就把成本与延迟砍掉一个数量级。第二原则是批处理:embedding API 支持一次传一批,单条调用 100 个 chunk 的吞吐远高于 100 次单条。第三是 chunk 与 embed 解耦——chunk 策略会反复调,但只要文本没变就不该重新付费嵌入。

┌────────── Embedding ETL:哈希缓存是核心阀门 ──────────┐ │ │ │ raw docs ─▶ extract ─▶ chunk ─▶ ┌──────────────┐ │ │ (毫秒级,几乎免费) │ hash(model+ │ │ │ │ chunk_text) │ │ │ └──────┬───────┘ │ │ hit? ──┤ │ │ ┌── yes ─────┘── no ──┐ │ │ ▼ ▼ │ │ cached vector ┌──────────┐│ │ (0 成本/延迟) │ embed() ││ ◀ 唯一付费/限流步 │ │ │ 批 100 ││ │ │ └────┬─────┘│ │ └──────┬────────────┘ │ │ ▼ │ │ upsert → vector DB │ └───────────────────────────────────────────────────────┘

实战示例

# 嵌入即纯函数:内容哈希做缓存键 + 批处理
import hashlib, json

MODEL = "voyage-3"  # 把 model_id 写进 key,换模型自动 miss

def key(text): return hashlib.sha256(f"{MODEL}:{text}".encode()).hexdigest()

def embed_batch(chunks, cache):
    todo = [c for c in chunks if key(c) not in cache]
    if todo:                                  # 只为未命中的付费
        vecs = client.embed(todo, model=MODEL).embeddings
        for c, v in zip(todo, vecs): cache[key(c)] = v
    return [cache[key(c)] for c in chunks]

model_id 编进缓存键这一行,是为下一节「换模型」埋的伏笔——换 model 会让所有 key 自动失效,逼你重嵌,而不是悄悄混用两个坐标系的向量。

失败模式:用「文档 ID + 第几块」当缓存键。文档内容一改,块的内容变了但 ID/序号没变,缓存命中拿到旧向量——静默返回过期嵌入,比报错更难查。缓存键必须含内容本身的哈希,不能只含位置。
进阶资源 · Anthropic Contextual Retrieval(嵌入前给每块补全上下文,召回失败率 -35%),anthropic.com/news/contextual-retrieval
// 02

增量索引:diff 驱动 upsert,别忘了删除墓碑

论断:每次全量重嵌是新手税;正确做法是按内容哈希算 diff,只动变化的块——并显式处理删除。

背景与原理

文档库是活的:新增、修改、删除每天都在发生。全量重建在文档少时能忍,过万就成了每日数小时 + 成倍 API 账单的负担。增量索引的核心是维护一张「块指纹表」chunk_id → content_hash),每次 ingest 与上一次对比,得出三类操作:新增(新 hash)、变更(同 id 不同 hash → 重嵌 + upsert)、删除(上次有这次没有 → 从向量库删)。前两类大多数人会做,删除几乎总被漏掉——因为「不报错」。漏删的后果是幽灵召回:文档早已下架,它的向量还躺在库里被检索到,模型据此生成已经过时甚至错误的答案。删除要走tombstone(墓碑标记)流程,确保向量库里那条真的消失。

实战示例

# diff 三态:新增 / 变更 / 删除(删除最容易漏)
def reindex(new_chunks, fingerprint_store, vdb):
    old = fingerprint_store.load()        # {chunk_id: hash}
    new = {c.id: c.hash for c in new_chunks}

    changed = [c for c in new_chunks
               if old.get(c.id) != c.hash]      # 新增 + 变更
    deleted = old.keys() - new.keys()     # ← 关键:上次有这次无

    if changed: vdb.upsert(embed_batch([c.text for c in changed]))
    if deleted: vdb.delete(ids=list(deleted))  # tombstone,杜绝幽灵召回
    fingerprint_store.save(new)

指纹表本身就是一份廉价的「数据版本快照」——出问题时可以拿它和向量库对账,找出索引与源数据漂移的那几条。

失败模式:增量只做 upsert,不做 delete。半年后向量库里堆满已删文档的残影,召回质量缓慢下降却查不出原因。第二个坑:把整篇文档当一个单位重嵌——只要改一个错别字就重嵌全文几十块。diff 粒度要到 chunk,不是 document
进阶资源 · DVC(git-for-data,把数据集与 pipeline 阶段版本化、可复现),doc.dvc.org/user-guide/pipelines
// 03

嵌入版本:换 model = 换坐标系,必须蓝绿重建

论断:不同 embedding model 产出的向量不在同一空间,混用就是拿两套坐标算距离——结果是噪声。

背景与原理

这是最隐蔽、代价最大的一类事故。embedding model 每次升级(甚至同名模型的小版本),向量空间的几何都变了:同一句话被 model A 和 model B 嵌出的两个向量,余弦相似度毫无意义。如果你「为了省钱」只重嵌新文档、旧文档保留旧向量,整个索引就混进了两套不可比的坐标系,召回从此随机退化——而且不报错。正确做法借用部署里的蓝绿(blue-green)思路:新模型在一个独立的新索引里全量重建,期间双写、灰度对比召回质量,确认不退化后再原子切换流量,旧索引留作回滚。关键纪律:每条向量的元数据里写死它的 embedding_model 版本,让混用在查询层就能被断言拦下。

┌────── 换 embedding model:蓝绿索引迁移 ──────┐ │ │ │ 写入 ──┬──▶ [Blue index] model v1 ◀─ 线上查询 │ │ (旧, 全量) │ │ └──▶ [Green index] model v2 │ │ (新, 后台全量重嵌) │ │ │ │ │ 灰度对比召回质量 │ │ (offline eval 集) │ │ │ 不退化? │ │ ▼ atomic swap │ │ 写入 ──┬──▶ [Green index] model v2 ◀─ 线上查询 │ └──▶ [Blue index] 保留 → 可回滚 │ │ │ │ ✗ 反模式:同一索引里混 v1+v2 向量 → 距离失真 │ └──────────────────────────────────────────────┘

实战示例

# 向量元数据绑定模型版本,查询层断言同源
vdb.upsert(id=cid, vector=v, metadata={
    "embedding_model": "voyage-3",   # ← 版本指纹
    "indexed_at": ts, "doc_id": doc})

def search(q, vdb, model):
    assert vdb.meta("embedding_model") == model, \
        "query model != index model — 坐标系不一致!"
    return vdb.query(embed(q, model=model), top_k=10)
失败模式:维度相同就以为能混用。text-embedding-3 和某开源模型都输出 1024 维,shape 对得上、代码不报错——但语义空间完全不同,召回纯随机。维度兼容 ≠ 空间兼容。换模型没有「增量升级」,只有全量重建。
进阶资源 · Chip Huyen《Designing Machine Learning Systems》中关于 data/model 版本与迭代的章节,是数据 pipeline 版本治理的标准参考。
// 04

特征存储:离线/在线一致,防 training-serving skew

论断:给 reranker / 个性化喂的特征,训练时和线上必须是同一个值、同一个时点算出来的——否则离线指标好、线上崩。

背景与原理

RAG 不只有向量。reranker 排序、个性化召回、过滤规则往往要用结构化特征:用户近 7 天点击、文档热度、作者权威度。这里有两个经典陷阱。其一 training-serving skew:训练用离线批处理 SQL 算特征,线上用另一套实时代码算——两套逻辑必然漂移,于是离线评估涨、线上掉。其二 数据泄漏:构造训练样本时,特征值要按当时那个预测时点(point-in-time)取,而不是「现在」——否则等于让模型在训练时偷看了未来,离线分虚高、上线打回原形。特征存储(feature store,如 Feast)就是为这两件事而生:离线 store 供训练、在线 store 供低延迟服务,同一份特征定义只写一次,并在离线侧实现 point-in-time 正确 join。

实战示例

# point-in-time join:按预测时点取特征,杜绝未来泄漏
# 训练样本 = (entity, 预测发生的 event_timestamp, label)
training_df = store.get_historical_features(
    entity_df=labels,                  # 含 event_timestamp 列
    features=["user:clicks_7d", "doc:popularity"],
).to_df()   # Feast 只取 ≤ event_timestamp 的特征值,不取"现在"

# 线上服务:同一份特征定义,低延迟在线 store
feats = store.get_online_features(
    features=["user:clicks_7d", "doc:popularity"],
    entity_rows=[{"user_id": u, "doc_id": d}],
).to_dict()   # 训练/服务复用同一定义 → 无 skew
失败模式:用一条 SELECT ... WHERE now() 给历史样本回填特征。这等于给三个月前的样本贴上今天的点击数——典型 point-in-time 泄漏,离线 AUC 漂亮得离谱,上线归零。任何带时间属性的特征,回填时都要问:这个值在预测发生那一刻真的存在吗?
进阶资源 · Feast 官方文档(开源 feature store,离线/在线双 store + point-in-time),docs.feast.dev

// 综合实战 · 给你的 RAG 装一条「可维护」的数据 pipeline

把四点串成一次重构:让你那个「一次性脚本」的 RAG 进化成能日更、能换模型、不退化的流水线。

  1. 哈希缓存层(§1):在 embed 前加 hash(model_id + chunk_text) 缓存。把今天的全量 ingest 跑两遍,确认第二遍命中率接近 100%、几乎零成本。
  2. 指纹表 + diff(§2):建 chunk_id → content_hash 表,ingest 改成三态 diff。专门测一次删除:删掉一篇文档,确认它的向量真的从库里消失、不再被召回。
  3. 版本元数据(§3):给每条向量写 embedding_model,查询层加断言。模拟一次换模型:建 green 索引全量重嵌,用一个固定 eval 集对比 blue/green 召回,确认不退化再切。
  4. 特征 point-in-time(§4):若有 reranker 特征,把回填 SQL 改成按 event_timestamp 取值。故意写一版「用 now() 回填」的对照,看离线指标虚高多少——亲手感受泄漏的诱惑有多大。

做完这四步,你的 RAG 从「demo 能跑」升级到「线上能养」。以后看任何向量检索系统,你会本能地问四个问题:嵌入有没有缓存、增量删没删干净、换模型怎么迁、特征会不会泄漏——而不是只盯着 top_k 调参。

// ENGLISH GLOSSARY

Embedding ETL
把原始内容抽取、切块、嵌入、写入向量库的数据流水线。embed 是其中唯一付费/限流步。
Content Hash
对内容本身(非位置/ID)取哈希,作缓存键与 diff 依据。内容变则 hash 变。
Incremental Indexing
只重嵌发生变化的块,而非全量重建。靠指纹表 diff 驱动。
Tombstone
删除墓碑:显式把已删内容的向量从库中移除,防幽灵召回。
Ghost Recall
幽灵召回:已删除/过期文档的向量仍被检索命中,污染生成。
Blue-Green Index
新旧两套索引并存、灰度对比后原子切换的迁移策略。换 embedding model 必用。
Embedding Space
某个 embedding model 定义的向量几何空间。不同模型的空间不可比。
Feature Store
统一管理 ML 特征的系统,离线供训练、在线供服务,定义只写一次(如 Feast)。
Training-Serving Skew
训练与线上特征计算逻辑漂移,导致离线好、线上崩。
Point-in-Time Correctness
构造训练样本时按预测时点取特征值,杜绝未来数据泄漏。

// 深入思考

Contextual Retrieval 要在嵌入前给每块补上下文,这会让 §1 的内容哈希缓存失效吗?
会,而且这恰恰是设计要点。补全后的「上下文 + 原块」才是真正被嵌入的文本,所以哈希必须算在补全后的字符串上。代价是:源块没变、但生成上下文的 LLM 或 prompt 一变,缓存就该失效。实务做法是把「上下文生成」也看作 pipeline 的一个版本化阶段,把它的版本号一并编进哈希键——和把 model_id 编进键是同一个思路:任何会改变最终向量的输入,都必须进缓存键。
§2 的指纹表和 §3 的蓝绿索引、§1 的哈希缓存,是三套独立机制还是一套?
本质是同一个「内容寻址」思想在三个尺度上的展开。哈希缓存解决「这块要不要重新付费嵌入」(块级、跨时间复用);指纹表解决「这次 ingest 哪些块变了」(库级、相邻两次对比);蓝绿解决「整个空间换了,怎么安全迁移」(索引级、模型升级)。三者共享一个不变式:用内容/版本的指纹,而非位置或时间,来判定身份与失效。理解这条主线,就不会把它们当成三个孤立 trick 去记。
「换 embedding model 必须全量重建」成本极高。有没有不重建就迁移的捷径?
研究上有「向量空间对齐」(学一个线性/小网络把旧空间映射到新空间)的尝试,但工程上几乎不可信赖:映射有损、误差会在召回里放大,且无法验证对齐质量是否均匀。靠谱的省钱方向不是避免重嵌,而是降低重嵌单价——§1 的缓存让没变的块免费、batch 降低单位成本、错峰用 batch API 拿折扣。结论:重建无法绕过,但可以让它便宜且可灰度。把钱花在蓝绿切换的安全性上,比赌一个对齐模型划算。
§4 的 point-in-time 泄漏,和 §2 漏删导致的幽灵召回,本质是同一类错误吗?
是——都是时间一致性被破坏。幽灵召回是「过去删了,现在的索引没跟上」(索引落后于现实);point-in-time 泄漏是「现在的值,被贴到了过去的样本上」(特征超前于现实)。一个滞后、一个超前,但根因相同:数据的有效时间(valid-time)没有被严肃对待。把每条数据都当成带时间戳的事件、任何读取都明确「以哪个时点为准」,两类 bug 会一起消失。这也是 feature store 和 CDC/事件溯源思想能迁移到 RAG 的原因。
个人开发者做 RAG,这四层工程是必要的还是过度设计?
分阶段。文档静态、几百篇、模型不换——四层都可省,一个全量脚本足矣,强上反而是过度工程。但只要命中任一信号就该补对应层:文档每天变 → §2 增量;想试更好的 embedding → §3 版本元数据(哪怕只写一行 metadata,未来省一场事故);接了 reranker 特征 → §4 point-in-time。判据不是「项目大小」,而是「数据会不会变、模型会不会换」。会变的系统,越早埋指纹与版本,越晚被它反噬。

// 延伸阅读